diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 258d5a1d5..c4e70658f 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -215,3 +215,15 @@ TODO ## [Reusable release and tag workflow](_release_and_tag.yml) TODO + +## [Build and publish Docker images](docker_publish.yml) + +This workflow builds the `greedybear` and `greedybear_nginx` images and publishes them to the **GitHub Container Registry (GHCR)** under `ghcr.io/greedybear-project`, decoupling image hosting from the IntelOwl-owned DockerHub account. + +It runs on: + +* pushes to **main** — tagged `prod` +* pushes to **develop** — tagged `stag` +* pushes of a **`X.Y.Z` git tag** — tagged with that version + +Each image is built via a job matrix and pushed by [**docker/build-push-action**](https://github.com/docker/build-push-action), with tags/labels derived by [**docker/metadata-action**](https://github.com/docker/metadata-action) and layer caching backed by the GitHub Actions cache. diff --git a/.github/workflows/docker_publish.yml b/.github/workflows/docker_publish.yml new file mode 100644 index 000000000..7dd372aaf --- /dev/null +++ b/.github/workflows/docker_publish.yml @@ -0,0 +1,76 @@ +name: Build and publish Docker images + +# Publishes the greedybear and greedybear_nginx images to the +# GitHub Container Registry (GHCR) under ghcr.io/greedybear-project. + +on: + push: + branches: + - main + - develop + tags: + - "[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + +# cancel superseded runs for the same ref +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GHCR_NAMESPACE: ghcr.io/greedybear-project + +jobs: + build-and-push: + name: Build and push ${{ matrix.image }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - image: greedybear + dockerfile: docker/Dockerfile + - image: greedybear_nginx + dockerfile: docker/Dockerfile_nginx + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.GHCR_NAMESPACE }}/${{ matrix.image }} + # main branch -> prod + # develop branch -> stag + # X.Y.Z git tag -> X.Y.Z + tags: | + type=raw,value=prod,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=stag,enable=${{ github.ref == 'refs/heads/develop' }} + type=semver,pattern={{version}} + flavor: | + latest=false + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }} diff --git a/api/apps.py b/api/apps.py index cbe2c1784..d106b2185 100644 --- a/api/apps.py +++ b/api/apps.py @@ -1,6 +1,24 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. from django.apps import AppConfig +from drf_spectacular.extensions import OpenApiAuthenticationExtension + + +class CookieTokenAuthScheme(OpenApiAuthenticationExtension): + """Tell drf-spectacular how to represent certego-saas' CookieTokenAuthentication + as an OpenAPI security scheme, so authenticated endpoints document the Token + header (and get an "Authorize" entry in Swagger UI).""" + + target_class = "certego_saas.apps.auth.backend.CookieTokenAuthentication" + name = "tokenAuth" + + def get_security_definition(self, auto_schema): + return { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Durin token auth. Use header `Authorization: Token `.", + } class ApiConfig(AppConfig): diff --git a/api/filters.py b/api/filters.py new file mode 100644 index 000000000..49a5b91ca --- /dev/null +++ b/api/filters.py @@ -0,0 +1,74 @@ +from datetime import timedelta + +import django_filters +from django.db.models import Count, QuerySet +from django.utils import timezone + +from greedybear.models import IOC + + +class FeedsFilterSet(django_filters.FilterSet): + asn = django_filters.NumberFilter(field_name="autonomous_system__asn") + min_score = django_filters.NumberFilter(field_name="recurrence_probability", lookup_expr="gte") + min_expected_interactions = django_filters.NumberFilter(field_name="expected_interactions", lookup_expr="gte") + start_date = django_filters.DateFilter(field_name="last_seen", lookup_expr="gte") + end_date = django_filters.DateFilter(field_name="last_seen", lookup_expr="lte") + tag_key = django_filters.CharFilter(field_name="tags__key", lookup_expr="iexact") + tag_value = django_filters.CharFilter(field_name="tags__value", lookup_expr="icontains") + + attack_type = django_filters.CharFilter(method="filter_attack_type") + ioc_type = django_filters.CharFilter(method="filter_ioc_type") + port = django_filters.NumberFilter(method="filter_port") + country_code = django_filters.CharFilter(method="filter_country_code") + min_days_seen = django_filters.NumberFilter(method="filter_min_days_seen") + max_age = django_filters.NumberFilter(method="filter_max_age") + include_reputation = django_filters.Filter(method="filter_include_reputation") + min_credential_count = django_filters.NumberFilter(method="filter_min_credential_count") + max_credential_count = django_filters.NumberFilter(method="filter_max_credential_count") + + class Meta: + model = IOC + fields = [] # all filters are declared explicitly above + + def filter_attack_type(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + if value and value != "all": + return queryset.filter(**{value: True}) + return queryset + + def filter_ioc_type(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + if value and value != "all": + return queryset.filter(type=value) + return queryset + + def filter_port(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + return queryset.filter(destination_ports__contains=[int(value)]) + + def filter_country_code(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + return queryset.filter(attacker_country_code=value.upper()) + + def filter_min_days_seen(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + if value and value > 1: + return queryset.filter(number_of_days_seen__gte=value) + return queryset + + def filter_include_reputation(self, queryset: QuerySet, name: str, value: list[str]) -> QuerySet: + if value: + return queryset.filter(ip_reputation__in=value) + return queryset + + def filter_min_credential_count(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + return self._filter_by_credential_count(queryset, "gte", value) + + def filter_max_credential_count(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + return self._filter_by_credential_count(queryset, "lte", value) + + def _filter_by_credential_count(self, queryset: QuerySet, lookup: str, value: int) -> QuerySet: + qualifying = IOC.objects.annotate(cc=Count("credentials", distinct=True)).filter(**{f"cc__{lookup}": value}).values("id") + return queryset.filter(id__in=qualifying) + + def filter_max_age(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + # drop max_age id an explicit date range replaces is set + if self.data.get("start_date") or self.data.get("end_date"): + return queryset + cutoff = timezone.now() - timedelta(days=int(value)) + return queryset.filter(last_seen__gte=cutoff) diff --git a/api/mixins.py b/api/mixins.py new file mode 100644 index 000000000..de2289ef5 --- /dev/null +++ b/api/mixins.py @@ -0,0 +1,89 @@ +import logging +import urllib.parse +from functools import cached_property + +from django.http import HttpResponse +from rest_framework.request import Request +from rest_framework.response import Response + +from greedybear.cache import Cache, build_versioned_key +from greedybear.consts import API_CACHE_ALIAS, API_CACHE_TIMEOUT_SECONDS, IOC_DATA_VERSION_KEY + +logger = logging.getLogger(__name__) + + +class CachedResponseMixin: + """Adds versioned response caching to an APIView. + Subclasses opt in by setting cache_namespace.""" + + cache = Cache(API_CACHE_ALIAS) + cache_namespace: str | None = None + cache_version_key: str = IOC_DATA_VERSION_KEY + cache_timeout: int = API_CACHE_TIMEOUT_SECONDS + + @cached_property + def cache_key(self) -> str | None: + """Versioned cache key for this request, or None when caching is disabled. + Computed once on first access (during the read) + and memoized on the per-request view instance.""" + if not self._cache_enabled(): + return None + version = self.cache.get_data_version(self.cache_version_key) + sorted_params = sorted(self.request.query_params.lists()) + params_string = urllib.parse.urlencode(sorted_params, doseq=True) + key_material = f"{self.__class__.__name__}|{self.request.path}|{params_string}" + return build_versioned_key(self.cache_namespace, version, key_material) + + def get_cached_response(self) -> HttpResponse | None: + """Return a hit, or None when caching is disabled or on a miss.""" + if self.cache_key is None: + return None + cached = self.cache.get(self.cache_key) + if cached is None: + return None + return HttpResponse(cached["content"], content_type=cached["content_type"], status=cached["status"]) + + def finalize_response(self, request, response, *args, **kwargs): + """Cache the rendered response under the key captured during the read. + Runs after DRF has attached the renderer to the response.""" + response = super().finalize_response(request, response, *args, **kwargs) + self._store_api_response(response) + return response + + def _cache_enabled(self) -> bool: + """Determines if cache was enabled + by checking if cache_namespace is set.""" + return self.cache_namespace is not None + + def _store_api_response(self, response: Response) -> bool: + """Store a successful (200) and rendered DRF Response under cache_key.""" + if self.cache_key is None: + return False + if not isinstance(response, Response): + return False + if response.status_code != 200: + return False + if not response.is_rendered: + response.render() + self.cache.set( + self.cache_key, + { + "content": response.rendered_content, + "content_type": response["content-type"], + "status": response.status_code, + }, + timeout=self.cache_timeout, + ) + return True + + +class RequestLoggingMixin: + """Emit a access-log line per request for any APIView/ViewSet it is mixed into.""" + + EXCLUDED_LOG_PARAMS = frozenset({"reason"}) + + def initial(self, request: Request, *args, **kwargs): + route = getattr(getattr(request, "resolver_match", None), "route", None) or request.path.lstrip("/") + params = {k: v for k, v in request.query_params.items() if k not in self.EXCLUDED_LOG_PARAMS} + logger.info(f"request {request.method} /{route} params: {params}") + super().initial(request, *args, **kwargs) diff --git a/api/permissions.py b/api/permissions.py new file mode 100644 index 000000000..8f5af1b81 --- /dev/null +++ b/api/permissions.py @@ -0,0 +1,22 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +from rest_framework.permissions import SAFE_METHODS, BasePermission + +THREAT_RESEARCHER_GROUP = "threat_researcher" + + +class IsThreatResearcherOrAdmin(BasePermission): + """Allow access only to staff users or members of the ``threat_researcher`` group.""" + + def has_permission(self, request, view): + user = request.user + return user and user.is_authenticated and (user.is_staff or user.groups.filter(name=THREAT_RESEARCHER_GROUP).exists()) + + +class IsSuperuserOrReadOnly(BasePermission): + """Allow read access to anyone; restrict write access to superusers only.""" + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + return request.user and request.user.is_authenticated and request.user.is_superuser diff --git a/api/renderers.py b/api/renderers.py new file mode 100644 index 000000000..7fcc8603a --- /dev/null +++ b/api/renderers.py @@ -0,0 +1,90 @@ +import csv +import io + +from django.conf import settings +from django.db.models import QuerySet +from rest_framework.renderers import BaseRenderer, JSONRenderer + +from api.views.utils import build_feed_dict, build_stix_bundle + + +class FeedJSONRenderer(JSONRenderer): + """JSON feed renderer. + When the `build_feed_envelope` context flag is set, it shapes the raw IOC + rows into the {"iocs": [...], "license": ...} envelope. + Any other payload (ASN list, pagination envelope) is rendered as plain JSON. + """ + + def render(self, data, accepted_media_type=None, renderer_context=None): + context = renderer_context or {} + if context.get("build_feed_envelope"): + data = build_feed_dict( + data, + verbose=context.get("verbose", False), + include_sensors=context.get("include_sensors", False), + ) + return super().render(data, accepted_media_type, renderer_context) + + +class FeedNDJSONRenderer(BaseRenderer): + media_type = "application/x-ndjson" + format = "ndjson" + charset = "utf-8" + + # FeedNDJSONRenderer exists only for DRF content negotiation, actual streaming is handled via StreamingHttpResponse in render_response(). + def render(self, data, accepted_media_type=None, renderer_context=None): + return b"" + + +class FeedRendererMixin(BaseRenderer): + """Shared safety net for the non-JSON feed renderers.""" + + def render(self, data, accepted_media_type=None, renderer_context=None): + if not isinstance(data, QuerySet): + return JSONRenderer().render(data, accepted_media_type, renderer_context) + return self.render_feed(data, accepted_media_type, renderer_context) + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + raise NotImplementedError + + +class FeedTextRenderer(FeedRendererMixin): + """Plain-text feed: one IOC value per line, prefixed by the license comment.""" + + media_type = "text/plain" + format = "txt" + charset = None + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + lines = [f"# {settings.FEEDS_LICENSE}"] if settings.FEEDS_LICENSE else [] + lines += [row[0] for row in data.values_list("name")] + return "\n".join(lines).encode("utf-8") + + +class FeedCSVRenderer(FeedRendererMixin): + """CSV feed: one IOC value per row, prefixed by the license comment.""" + + media_type = "text/csv" + format = "csv" + charset = None + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + buffer = io.StringIO() + writer = csv.writer(buffer, quoting=csv.QUOTE_MINIMAL) + if settings.FEEDS_LICENSE: + buffer.write("# ") + writer.writerow([settings.FEEDS_LICENSE]) + writer.writerows([list(row) for row in data.values_list("name")]) + return buffer.getvalue().encode("utf-8") + + +class Stix21Renderer(FeedRendererMixin): + """STIX 2.1 bundle feed (served as application/json).""" + + media_type = "application/json" + format = "stix21" + charset = None + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + request = (renderer_context or {}).get("request") + return build_stix_bundle(data, request=request).encode("utf-8") diff --git a/api/serializers.py b/api/serializers.py deleted file mode 100644 index e08bac4fa..000000000 --- a/api/serializers.py +++ /dev/null @@ -1,253 +0,0 @@ -import logging -import re -from functools import cache - -from django.core.exceptions import FieldDoesNotExist -from rest_framework import serializers - -from greedybear.consts import REGEX_DOMAIN -from greedybear.models import IOC, Honeypot, Sensor, Tag -from greedybear.utils import is_ip_address - -logger = logging.getLogger(__name__) - - -class HoneypotSerializer(serializers.ModelSerializer): - class Meta: - model = Honeypot - - def to_representation(self, value): - return value.name - - -class TagSerializer(serializers.ModelSerializer): - class Meta: - model = Tag - fields = ["key", "value", "source"] - - -class SensorSerializer(serializers.ModelSerializer): - class Meta: - model = Sensor - fields = ["address", "label"] - - -class IOCSerializer(serializers.ModelSerializer): - general_honeypot = HoneypotSerializer(many=True, read_only=True, source="honeypots") - tags = TagSerializer(many=True, read_only=True) - sensors = SensorSerializer(many=True, read_only=True) - - class Meta: - model = IOC - exclude = [ - "related_urls", - ] - - -class EnrichmentSerializer(serializers.Serializer): - found = serializers.BooleanField(read_only=True, default=False) - ioc = IOCSerializer(read_only=True, default=None) - query = serializers.CharField(max_length=250) - - def validate(self, data): - """ - Validate that the query is a valid IP address (IPv4/IPv6) or domain. - """ - observable = data["query"].strip() - data["query"] = observable - - # A valid domain must match the domain regex AND contain at least one alphabetic character - is_domain = bool(re.match(REGEX_DOMAIN, observable)) and any(c.isalpha() for c in observable) - - if not is_ip_address(observable) and not is_domain: - raise serializers.ValidationError("Observable is not a valid IP address or domain") - - try: - required_object = IOC.objects.prefetch_related("tags", "sensors").get(name=observable) - data["found"] = True - data["ioc"] = required_object - except IOC.DoesNotExist: - data["found"] = False - return data - - -def parse_feed_types(feed_type_str: str) -> list: - """Split a comma-separated feed type string into a stripped list of individual feed types. - - Args: - feed_type_str (str): Comma-separated feed type string (e.g. "cowrie,adbhoney"). - - Returns: - list[str]: List of non-empty, stripped feed type tokens. - """ - return [ft.strip() for ft in feed_type_str.split(",") if ft.strip()] - - -def feed_type_validation(feed_type: str, valid_feed_types: frozenset) -> str: - """Validates that a given feed type exists in the set of valid feed types. - - Args: - feed_type (str): The feed type to validate - valid_feed_types (frozenset): Set of allowed feed type values - - Returns: - str: The validated feed type string, unchanged - - Raises: - serializers.ValidationError: If feed_type is not found in valid_feed_types - """ - if feed_type not in valid_feed_types: - logger.info(f"Feed type {feed_type} not in feed_choices {valid_feed_types}") - raise serializers.ValidationError(f"Invalid feed_type: {feed_type}") - return feed_type - - -@cache -def ordering_validation(ordering: str) -> str: - """Validates that given ordering corresponds to a field in the IOC model. - - Args: - ordering (str): The ordering to validate - - Returns: - str: The validated ordering string, unchanged - - Raises: - serializers.ValidationError: If ordering does not correspond to a field in the IOC model - """ - if not ordering: - raise serializers.ValidationError("Invalid ordering: ") - # remove minus sign if present - field_name = ordering.removeprefix("-") - try: - IOC._meta.get_field(field_name) - except FieldDoesNotExist as exc: - raise serializers.ValidationError(f"Invalid ordering: {ordering}") from exc - return ordering - - -class FeedsRequestSerializer(serializers.Serializer): - feed_type = serializers.CharField() - attack_type = serializers.ChoiceField(choices=["scanner", "payload_request", "all"]) - ioc_type = serializers.ChoiceField(choices=["ip", "domain", "all"]) - max_age = serializers.IntegerField(min_value=1) - min_days_seen = serializers.IntegerField(min_value=1) - min_credential_count = serializers.IntegerField(required=False, min_value=1) - max_credential_count = serializers.IntegerField(required=False, min_value=0) - include_reputation = serializers.ListField(child=serializers.CharField(max_length=120)) - exclude_reputation = serializers.ListField(child=serializers.CharField(max_length=120)) - feed_size = serializers.IntegerField(min_value=1) - ordering = serializers.CharField(max_length=120) - verbose = serializers.ChoiceField(choices=["true", "false"]) - paginate = serializers.ChoiceField(choices=["true", "false"]) - format = serializers.ChoiceField(choices=["csv", "json", "txt", "stix21"]) - asn = serializers.IntegerField(min_value=1, required=False, allow_null=True) - min_score = serializers.FloatField(min_value=0, max_value=1, required=False, allow_null=True) - min_expected_interactions = serializers.FloatField(min_value=0, required=False, allow_null=True) - port = serializers.IntegerField(min_value=1, max_value=65535, required=False, allow_null=True) - start_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True) - end_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True) - tag_key = serializers.CharField(max_length=128, required=False, allow_blank=True) - tag_value = serializers.CharField(max_length=256, required=False, allow_blank=True) - country_code = serializers.CharField(max_length=2, required=False, allow_blank=True) - - def validate_feed_type(self, feed_type): - logger.debug(f"FeedsRequestSerializer - validation feed_type: '{feed_type}'") - feed_types = parse_feed_types(feed_type) - if not feed_types: - raise serializers.ValidationError("Invalid feed_type: must not be empty") - valid_feed_types = self.context["valid_feed_types"] - if len(feed_types) > len(valid_feed_types): - raise serializers.ValidationError(f"Invalid feed_type: too many types specified (max {len(valid_feed_types)})") - if "all" in feed_types and len(feed_types) > 1: - raise serializers.ValidationError("Invalid feed_type: 'all' cannot be combined with other feed types") - for ft in feed_types: - feed_type_validation(ft, valid_feed_types) - return feed_type - - def validate_ordering(self, ordering): - logger.debug(f"FeedsRequestSerializer - validation ordering: '{ordering}'") - return ordering_validation(ordering) - - def validate(self, data): - min_cc = data.get("min_credential_count") - max_cc = data.get("max_credential_count") - if min_cc is not None and max_cc is not None and min_cc > max_cc: - raise serializers.ValidationError("min_credential_count must be less than or equal to max_credential_count") - return data - - -class ASNFeedsOrderingSerializer(FeedsRequestSerializer): - ALLOWED_ORDERING_FIELDS = frozenset( - { - "asn", - "as_name", - "ioc_count", - "total_attack_count", - "total_interaction_count", - "total_login_attempts", - "expected_ioc_count", - "expected_interactions", - "first_seen", - "last_seen", - } - ) - - def validate_ordering(self, ordering): - field_name = ordering.lstrip("-").strip() - - if field_name not in self.ALLOWED_ORDERING_FIELDS: - raise serializers.ValidationError( - f"Invalid ordering field for ASN aggregated feed: '{field_name}'. Allowed fields: {', '.join(sorted(self.ALLOWED_ORDERING_FIELDS))}" - ) - - return ordering - - -class FeedsResponseSerializer(serializers.Serializer): - """ - Serializer for feed response data structure. - - NOTE: This serializer is currently NOT used in production code (as of #629). - It has been kept in the codebase for the following reasons: - - 1. **Documentation**: Serves as a clear schema definition for the API response contract - 2. **Testing**: Validates the expected response structure through unit tests - 3. **Future-proofing**: Allows easy re-enabling of validation if security requirements change - 4. **Reference**: Useful for API consumers and developers to understand the response format - - Performance Optimization Context: - Previously, this serializer was instantiated and validated for each IOC in the response - (up to 5000 times per request), causing significant overhead (~1.8s for 5000 IOCs). - The optimization removed this per-item validation since the data is constructed internally - in api/views/utils.py::feeds_response() and guaranteed to match this schema. - - The response is now built directly without serializer validation, reducing response time - to ~0.03s (50-90x speedup) while maintaining the exact same API contract defined here. - - See: #629 for benchmarking details and discussion. - """ - - feed_type = serializers.ListField(child=serializers.CharField(max_length=120)) - value = serializers.CharField(max_length=256) - scanner = serializers.BooleanField() - payload_request = serializers.BooleanField() - first_seen = serializers.DateField(format="%Y-%m-%d") - last_seen = serializers.DateField(format="%Y-%m-%d") - attack_count = serializers.IntegerField(min_value=1) - interaction_count = serializers.IntegerField(min_value=1) - ip_reputation = serializers.CharField(allow_blank=True, max_length=32) - firehol_categories = serializers.ListField(child=serializers.CharField(max_length=64), allow_empty=True) - asn = serializers.IntegerField(allow_null=True, min_value=1) - destination_port_count = serializers.IntegerField(min_value=0) - login_attempts = serializers.IntegerField(min_value=0) - recurrence_probability = serializers.FloatField(min_value=0, max_value=1) - expected_interactions = serializers.FloatField(min_value=0) - attacker_country = serializers.CharField(allow_null=True, allow_blank=True, max_length=120) - attacker_country_code = serializers.CharField(allow_null=True, allow_blank=True, max_length=2) - tags = TagSerializer(many=True, required=False, default=list) - sensors = SensorSerializer(many=True, required=False, default=list) - - def validate_feed_type(self, feed_type): - logger.debug(f"FeedsResponseSerializer - validation feed_type: '{feed_type}'") - return [feed_type_validation(feed, self.context["valid_feed_types"]) for feed in feed_type] diff --git a/api/serializers/__init__.py b/api/serializers/__init__.py new file mode 100644 index 000000000..82f4444e3 --- /dev/null +++ b/api/serializers/__init__.py @@ -0,0 +1,7 @@ +from .common import * +from .cowrie_session import * +from .events import * +from .feeds import * +from .health import * +from .payloads import * +from .utils import * diff --git a/api/serializers/common.py b/api/serializers/common.py new file mode 100644 index 000000000..bd7c2fdb7 --- /dev/null +++ b/api/serializers/common.py @@ -0,0 +1,86 @@ +import logging +import re + +from rest_framework import serializers + +from api.serializers.utils import PresenceFlagField +from greedybear.consts import REGEX_DOMAIN +from greedybear.models import IOC, Sensor, Tag +from greedybear.utils import is_ip_address + +logger = logging.getLogger(__name__) + + +class HoneypotRelatedField(serializers.SlugRelatedField): + """Flattens a Honeypot relation to its bare name. Used for nested representations.""" + + def __init__(self, **kwargs): + kwargs.setdefault("slug_field", "name") + kwargs.setdefault("read_only", True) + super().__init__(**kwargs) + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ["key", "value", "source"] + + +class SensorSerializer(serializers.ModelSerializer): + class Meta: + model = Sensor + fields = ["address", "label"] + + +class IOCSerializer(serializers.ModelSerializer): + general_honeypot = HoneypotRelatedField(many=True, read_only=True, source="honeypots") + tags = TagSerializer(many=True, read_only=True) + sensors = SensorSerializer(many=True, read_only=True) + + class Meta: + model = IOC + exclude = [ + "related_urls", + ] + + +class EnrichmentRequestSerializer(serializers.Serializer): + query = serializers.CharField(max_length=250, help_text="The IP address or domain to lookup.") + + def validate(self, data): + """ + Validate that the query is a valid IP address (IPv4/IPv6) or domain. + """ + observable = data["query"].strip() + data["query"] = observable + + # A valid domain must match the domain regex AND contain at least one alphabetic character + is_domain = bool(re.match(REGEX_DOMAIN, observable)) and any(c.isalpha() for c in observable) + + if not is_ip_address(observable) and not is_domain: + raise serializers.ValidationError("Observable is not a valid IP address or domain") + return data + + +class EnrichmentSerializer(serializers.Serializer): + found = serializers.BooleanField(read_only=True, default=False) + ioc = IOCSerializer(read_only=True, default=None, allow_null=True) + query = serializers.CharField(max_length=250) + + +class HoneypotRequestSerializer(serializers.Serializer): + """Query params for the honeypot list endpoint. + ``onlyActive`` is the legacy camelCase spelling kept for backwards compatibility. + Either spelling enables the filter. + """ + + only_active = PresenceFlagField(default=False, help_text="Include only active honeypots.") + onlyActive = PresenceFlagField( # noqa: N815 + default=False, + help_text="Deprecated alias for only_active.", + ) + + def validate(self, data): + legacy_flag = data.pop("onlyActive") + data["only_active"] = data["only_active"] or legacy_flag + return data diff --git a/api/serializers/cowrie_session.py b/api/serializers/cowrie_session.py new file mode 100644 index 000000000..a00b31505 --- /dev/null +++ b/api/serializers/cowrie_session.py @@ -0,0 +1,52 @@ +from rest_framework import serializers + + +class CowrieSessionRequestSerializer(serializers.Serializer): + query = serializers.CharField( + max_length=256, + help_text=( + "The search term, can be an IP address, the SHA-256 hash of a command sequence, or a password. " + 'SHA-256 hashes should match command sequences generated using Python\'s `"\n".join(sequence)` format.' + ), + ) + include_similar = serializers.BooleanField( + required=False, + default=False, + help_text=( + "When `true`, expands the result to include all sessions that executed command sequences " + "belonging to the same cluster(s) as command sequences found in the initial query result. " + "Requires CLUSTER_COWRIE_COMMAND_SEQUENCES enabled in configuration." + ), + ) + include_credentials = serializers.BooleanField( + required=False, default=False, help_text="When `true`, includes all credentials used across matching Cowrie sessions." + ) + include_session_data = serializers.BooleanField( + required=False, default=False, help_text="When `true`, includes detailed information about matching Cowrie sessions." + ) + + +class SessionDetailSerializer(serializers.Serializer): + """A single matching Cowrie session.""" + + time = serializers.DateTimeField(help_text="Session start time.") + duration = serializers.FloatField(help_text="Session duration in seconds.") + source = serializers.IPAddressField(help_text="Source IP address.") + interactions = serializers.IntegerField() + credentials = serializers.ListField(child=serializers.CharField(), help_text="Credentials used in this session, as `username | password`.") + commands = serializers.CharField(help_text="Command sequence executed, newline-delimited. Empty when the session ran no commands.") + + +class CowrieSessionSerializer(serializers.Serializer): + """Aggregated view of the sessions matching a query.""" + + query = serializers.CharField(max_length=256, help_text="The query this result was produced for.") + license = serializers.CharField(required=False, help_text="Present when a feed license is configured.") + commands = serializers.ListField(child=serializers.CharField(), help_text="Unique command sequences, each newline-delimited.") + sources = serializers.ListField(child=serializers.IPAddressField(), help_text="Unique source IP addresses.") + credentials = serializers.ListField( + child=serializers.CharField(), + required=False, + help_text="Unique credentials across all matching sessions. Present when `include_credentials` is true.", + ) + sessions = SessionDetailSerializer(many=True, required=False, help_text="Present when `include_session_data` is true.") diff --git a/api/serializers/events.py b/api/serializers/events.py new file mode 100644 index 000000000..2eda33fc4 --- /dev/null +++ b/api/serializers/events.py @@ -0,0 +1,167 @@ +import logging +import re + +from django.utils import timezone +from rest_framework import serializers + +from greedybear.models import EventStatus, Sensor +from greedybear.utils import is_ip_address + +logger = logging.getLogger(__name__) + + +class SensorCreateSerializer(serializers.ModelSerializer): + sensor_label = serializers.CharField( + source="label", + required=False, + allow_blank=True, + max_length=128, + help_text="Optional human-readable label to identify this sensor.", + ) + asn = serializers.IntegerField( + required=False, + allow_null=True, + min_value=1, + max_value=2147483647, + help_text="Autonomous System Number.", + ) + + class Meta: + model = Sensor + fields = [ + "id", + "address", + "honeypot_type", + "honeypot_software", + "honeypot_description", + "sensor_label", + "group_label", + "country_code", + "asn", + ] + + extra_kwargs = { + "address": { + "validators": [], # drops the model's uniqueness check, which ignores api_source scoping + "help_text": "IPv4 or IPv6 address of the sensor.", + }, + "honeypot_type": {"help_text": "Type of honeypot."}, + "honeypot_software": {"help_text": "Honeypot software name."}, + "honeypot_description": {"help_text": "Description of the sensor."}, + "group_label": {"help_text": "Group classification label."}, + "country_code": {"help_text": "2-letter ISO country code."}, + } + + read_only_fields = ["id"] + + def validate_country_code(self, value): + """ + Validates that the input is exactly 2 alphabet letters using regex, + and converts it to uppercase. + """ + if value: + # Regex pattern: ^[A-Za-z]{2}$ means exactly two letters (A-Z or a-z) + if not re.match(r"^[A-Za-z]{2}$", value): + raise serializers.ValidationError("Country code must be a 2-character ISO code containing letters only (e.g. 'NP', 'IN').") + return value.upper() + + return value + + def validate_address(self, value): + """ + Validates the address format. Required because Meta.extra_kwargs clears this field's validators. + """ + if not is_ip_address(value): + raise serializers.ValidationError("Invalid IP address") + return value + + +class SensorCreateResponseSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + message = serializers.CharField(read_only=True) + + +class EventSerializer(serializers.Serializer): + # required fields + src_ip = serializers.IPAddressField(required=True) + event_type = serializers.CharField(required=True, max_length=100) + timestamp = serializers.DateTimeField(required=True) + sensor_id = serializers.IntegerField(required=True, min_value=0) + + # optional string fields + session_id = serializers.CharField(default="", max_length=100, allow_blank=True) + token_id = serializers.CharField(default="", max_length=100, allow_blank=True) + protocol = serializers.CharField(default="", max_length=50, allow_blank=True) + service_name = serializers.CharField(default="", max_length=100, allow_blank=True) + username = serializers.CharField(default="", max_length=255, allow_blank=True) + password = serializers.CharField(default="", max_length=255, allow_blank=True) + cve_id = serializers.CharField(default="", max_length=50, allow_blank=True) + command = serializers.CharField(default="", allow_blank=True) + src_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) + dest_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) + related_url = serializers.URLField(default="", max_length=900, allow_blank=True) + payload_hash = serializers.CharField(default="", max_length=64, allow_blank=True) + data = serializers.JSONField(default=dict) + + def validate_timestamp(self, value): + if value > timezone.now(): + raise serializers.ValidationError("Timestamp cannot be in the future.") + return value + + def validate_payload_hash(self, value): + if value and not re.fullmatch(r"[a-fA-F0-9]{64}", value): + raise serializers.ValidationError("payload_hash must be a 64-character hex sha256 digest.") + return value.lower() if value else value + + def validate_protocol(self, value): + return value.strip().lower() if value else value + + def validate_cve_id(self, value): + value = value.strip().upper() + if value and not re.fullmatch(r"CVE-\d{4}-\d{4,}", value): + raise serializers.ValidationError("cve_id must follow the CVE format: CVE-YYYY-NNNNN (e.g. CVE-2021-44228).") + return value + + +class InjectionSerializer(serializers.Serializer): + events = serializers.ListField( + child=EventSerializer(), + min_length=1, + max_length=10000, + error_messages={"min_length": "At least one event is required.", "max_length": "Batch size cannot exceed 10,000 events."}, + ) + + +class InjectionResponseSerializer(serializers.Serializer): + message = serializers.CharField(read_only=True) + task_id = serializers.CharField(read_only=True) + status_url = serializers.CharField(read_only=True) + + +class BatchStatusRequestSerializer(serializers.Serializer): + task_id = serializers.RegexField( + r"^[0-9a-f]{32}$", + help_text="The unique string identifier assigned to the background processing job.", + error_messages={"invalid": "task_id must be a 32-character lowercase hex string."}, + ) + + +class BatchStatusSerializer(serializers.ModelSerializer): + batch_id = serializers.IntegerField(source="id", read_only=True) + last_error = serializers.SerializerMethodField() + + class Meta: + model = EventStatus + fields = [ + "task_id", + "batch_id", + "status", + "ioc_count", + "last_error", + "processed_at", + "created_at", + ] + + def get_last_error(self, obj) -> str | None: + """Normalizes the blank default to null, so clients only test for one empty value.""" + return obj.last_error or None diff --git a/api/serializers/feeds.py b/api/serializers/feeds.py new file mode 100644 index 000000000..5d993ad1d --- /dev/null +++ b/api/serializers/feeds.py @@ -0,0 +1,432 @@ +import hashlib +import logging +from collections.abc import Mapping + +from django.conf import settings +from django.core import signing +from django.core.exceptions import FieldDoesNotExist +from rest_framework import serializers + +from api.serializers.common import SensorSerializer, TagSerializer +from api.serializers.utils import PresenceFlagField, feed_type_as_list, get_valid_feed_types +from greedybear.consts import SHARE_TOKEN_MAX_AGE, SHARE_TOKEN_SALT +from greedybear.cronjobs.trending import validate_window_minutes +from greedybear.enums import IpReputation +from greedybear.models import IOC, ShareToken + +logger = logging.getLogger(__name__) + +FEED_DEFAULTS = { + "max_age": 3, + "min_days_seen": 1, + "feed_size": 5000, + "include_reputation": [], + "exclude_reputation": [], + "verbose": False, + "paginate": False, +} + +PRIORITIZATION_PRESETS = { + "recent": {"max_age": 3, "min_days_seen": 1, "ordering": "-last_seen"}, + "persistent": {"max_age": 14, "min_days_seen": 10, "ordering": "-attack_count"}, + "likely_to_recur": {"max_age": 30, "min_days_seen": 1, "ordering": "-recurrence_probability"}, + "most_expected_hits": {"max_age": 30, "min_days_seen": 1, "ordering": "-expected_interactions"}, +} + + +### FIELDS ### +class FeedTypeField(serializers.CharField): + """CharField for the feed_type query parameter. + Accepts a single value or a comma-separated list of strings. + Although the field is exposed as as CharField, + the internal representation is always a list. + """ + + def to_internal_value(self, data: str) -> list[str]: + feed_type_str = super().to_internal_value(data) + logger.debug(f"Validating feed_type: {feed_type_str}") + feed_type_list = feed_type_as_list(feed_type_str) + if not feed_type_list: + raise serializers.ValidationError("Invalid feed_type: must not be empty") + if "all" in feed_type_list and len(feed_type_list) > 1: + raise serializers.ValidationError("Invalid feed_type: 'all' cannot be combined with other feed types") + invalid_feed_types = set(feed_type_list) - get_valid_feed_types() + if invalid_feed_types: + raise serializers.ValidationError(f"Invalid feed_type: {', '.join(sorted(invalid_feed_types))} not supported") + return feed_type_list + + def get_default(self) -> list[str]: + """Convert the declared default ("all") to a list ["all"] + to match internal representaion of other values. + """ + return [super().get_default()] + + +class ReputationListField(serializers.ListField): + """ListField for the include_reputation and exclude_reputation query params. + Takes a query string that contains ``;``-separated values + and represents it as a list. + """ + + def to_internal_value(self, data: str) -> list[str]: + logger.debug(f"Converting reputation list: {data}") + reputations = data.split(";") if data else [] + return super().to_internal_value(reputations) + + +### REQUESTS ### +class BaseFeedRequestSerializer(serializers.Serializer): + """Shared base for the feed request serializers (simple, advanced, ASN). + Declares the parameters common to every feed endpoint + and the query-string normalization they all rely on. + + Not used directly as an endpoint serializer; + subclasses add their own fields and validation logic. + """ + + feed_type = FeedTypeField(default="all", help_text="Honeypot name (e.g. `cowrie`), a comma-separated list, or `all`.") + attack_type = serializers.ChoiceField( + choices=["scanner", "payload_request", "all"], default="all", help_text="Restrict to scanners, payload requests, or both." + ) + ioc_type = serializers.ChoiceField(choices=["ip", "domain", "all"], default="all", help_text="Restrict to IPs, domains, or both.") + ordering = serializers.CharField(default="-last_seen", help_text="IOC field to order by; prefix with `-` for descending (e.g. `-last_seen`).") + format = serializers.ChoiceField(choices=["csv", "json", "txt", "stix21", "ndjson"], default="json", help_text="Output representation.") + + def to_internal_value(self, data: Mapping) -> dict: + """Normalize raw query params before field validation: + - lower-case all string values + - accept format_ (legacy name) as an alias for format + """ + logger.debug("Normalizing raw query") + if isinstance(data, Mapping): + data = {key: value.lower() if isinstance(value, str) else value for key, value in data.items()} + if "format_" in data and "format" not in data: + data["format"] = data["format_"] + return super().to_internal_value(data) + + def validate_ordering(self, ordering: str) -> str: + """Validate ordering against the IOC model fields and return it normalized.""" + logger.debug(f"Validating ordering: {ordering}") + if not ordering: + raise serializers.ValidationError("Invalid ordering: ") + normalized_ordering = ordering.lower().replace("value", "name") + field_name = normalized_ordering.removeprefix("-") + try: + IOC._meta.get_field(field_name) + except FieldDoesNotExist as exc: + raise serializers.ValidationError(f"Invalid ordering: {ordering}") from exc + return normalized_ordering + + +class SimpleFeedRequestSerializer(BaseFeedRequestSerializer): + """Serializer for the public feed endpoints. + Exposes only a curated set of inputs + and expands them into the full parameter set the views consume + using presets and default fallbacks. + """ + + prioritize = serializers.ChoiceField( + choices=["recent", "persistent", "likely_to_recur", "most_expected_hits"], + default="recent", + help_text="Preset selecting the age window, minimum days seen and default ordering.", + ) + include_mass_scanners = PresenceFlagField(default=False, help_text="Include IOCs flagged as mass scanners.") + include_tor_exit_nodes = PresenceFlagField(default=False, help_text="Include IOCs that are Tor exit nodes.") + # allows explicit override of the ordering in PRIORITIZATION_PRESETS + ordering = serializers.CharField(required=False, help_text="Override the preset ordering, e.g. `-attack_count`.") + + def validate(self, data: dict) -> dict: + logger.debug("Validating simple feed request") + data = super().validate(data) + prioritization_preset = PRIORITIZATION_PRESETS[data["prioritize"]] + exclude_reputation = [] + if not data["include_mass_scanners"]: + exclude_reputation.append(IpReputation.MASS_SCANNER) + if not data["include_tor_exit_nodes"]: + exclude_reputation.append(IpReputation.TOR_EXIT_NODE) + return { + **FEED_DEFAULTS, + **prioritization_preset, + **data, + "exclude_reputation": exclude_reputation, + } + + +class AdvancedFeedRequestSerializer(BaseFeedRequestSerializer): + """Serializer for the authenticated advanced feed endpoint. + Exposes the full set of filtering, scoring and pagination parameters directly, + taking default fallback values from FEED_DEFAULTS. + """ + + max_age = serializers.IntegerField( + min_value=1, + default=FEED_DEFAULTS["max_age"], + help_text="Maximum age in days since an IOC was last seen. Ignored when `start_date` or `end_date` is given.", + ) + min_days_seen = serializers.IntegerField(min_value=1, default=FEED_DEFAULTS["min_days_seen"], help_text="Minimum distinct days an IOC has been seen.") + feed_size = serializers.IntegerField(min_value=1, default=FEED_DEFAULTS["feed_size"], help_text="Maximum number of IOCs to return.") + include_reputation = ReputationListField( + child=serializers.CharField(max_length=120), default=FEED_DEFAULTS["include_reputation"], help_text="`;`-separated reputations to include." + ) + exclude_reputation = ReputationListField( + child=serializers.CharField(max_length=120), default=FEED_DEFAULTS["exclude_reputation"], help_text="`;`-separated reputations to exclude." + ) + verbose = serializers.BooleanField(default=FEED_DEFAULTS["verbose"], help_text="Include extended per-IOC fields in JSON output.") + paginate = serializers.BooleanField(default=FEED_DEFAULTS["paginate"], help_text="Paginate the response (forces JSON output).") + min_credential_count = serializers.IntegerField(required=False, min_value=1, help_text="Only IOCs with at least this many associated credentials.") + max_credential_count = serializers.IntegerField(required=False, min_value=0, help_text="Only IOCs with at most this many associated credentials.") + asn = serializers.IntegerField(min_value=1, required=False, allow_null=True, help_text="Filter by autonomous system number.") + min_score = serializers.FloatField(min_value=0, max_value=1, required=False, allow_null=True, help_text="Minimum recurrence probability between 0 and 1.") + min_expected_interactions = serializers.FloatField(min_value=0, required=False, allow_null=True, help_text="Minimum expected interactions.") + port = serializers.IntegerField(min_value=1, max_value=65535, required=False, allow_null=True, help_text="Filter by attacked destination port.") + start_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True, help_text="Only IOCs last seen on or after this date (YYYY-MM-DD).") + end_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True, help_text="Only IOCs last seen on or before this date (YYYY-MM-DD).") + tag_key = serializers.CharField(max_length=128, required=False, allow_blank=True, help_text="Filter by tag key (case-insensitive exact match).") + tag_value = serializers.CharField(max_length=256, required=False, allow_blank=True, help_text="Filter by tag value.") + country_code = serializers.CharField(max_length=2, required=False, allow_blank=True, help_text="Filter by 2-letter attacker country code.") + + def validate(self, data: dict) -> dict: + logger.debug("Validating advanced feed request") + data = super().validate(data) + # .get() instead of [] so subclasses without the paginate field (ASN) can reuse this + if data.get("paginate"): + data["format"] = "json" + min_cc = data.get("min_credential_count") + max_cc = data.get("max_credential_count") + if min_cc is not None and max_cc is not None and min_cc > max_cc: + raise serializers.ValidationError("min_credential_count must be less than or equal to max_credential_count") + return data + + +class ASNFeedRequestSerializer(AdvancedFeedRequestSerializer): + """Serializer for the ASN-aggregated feed endpoint. + Restricts ordering to the aggregated fields in ALLOWED_ORDERING_FIELDS + rather than IOC model fields. + """ + + ALLOWED_ORDERING_FIELDS = frozenset( + { + "asn", + "as_name", + "ioc_count", + "total_attack_count", + "total_interaction_count", + "total_login_attempts", + "expected_ioc_count", + "expected_interactions", + "first_seen", + "last_seen", + } + ) + + # Remove inherited advanced fields that have no effect on the aggregated ASN feed + format = None + feed_size = None + verbose = None + paginate = None + + asn = serializers.IntegerField(min_value=1, required=False, allow_null=True, help_text="Only display results of this autonomous system number.") + ordering = serializers.CharField(default="-ioc_count", help_text="Aggregate field to order by (e.g. `-ioc_count`, `-total_attack_count`).") + + def validate_ordering(self, ordering: str) -> str: + logger.debug(f"Validating ordering: {ordering}") + field_name = ordering.removeprefix("-") + if field_name not in self.ALLOWED_ORDERING_FIELDS: + raise serializers.ValidationError( + f"Invalid ordering field for ASN aggregated feed: '{field_name}'. Allowed fields: {', '.join(sorted(self.ALLOWED_ORDERING_FIELDS))}" + ) + + return ordering + + +class ShareFeedRequestSerializer(AdvancedFeedRequestSerializer): + """Validate a share request: the advanced-feed params plus an optional reason note.""" + + reason = serializers.CharField( + required=False, allow_blank=True, default="", help_text="Optional free-text note stored for auditing (truncated to 256 chars)." + ) + + def to_internal_value(self, data: Mapping) -> dict: + validated = super().to_internal_value(data) + if isinstance(data, Mapping) and "reason" in data: + validated["reason"] = data["reason"].strip()[:256] # keep original case and truncate + return validated + + +class TrendingFeedRequestSerializer(serializers.Serializer): + feed_type = FeedTypeField(default="all", help_text="Honeypot name list or `all`.") + window_minutes = serializers.IntegerField(min_value=60, default=24 * 60, help_text="Completed comparison window size in minutes.") + limit = serializers.IntegerField(min_value=1, max_value=1000, default=10, help_text="Maximum number of attackers to return.") + + def to_internal_value(self, data: Mapping) -> dict: + if isinstance(data, Mapping): + data = {key: value.lower() if isinstance(value, str) else value for key, value in data.items()} + return super().to_internal_value(data) + + def validate_window_minutes(self, value: int) -> int: + try: + return validate_window_minutes(value, settings.TRENDING_MAX_WINDOW_MINUTES) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + + +class TokenRequestSerializer(serializers.Serializer): + """Resolve a signed share token to its DB record and decoded feed parameters.""" + + token = serializers.CharField(help_text="A valid and signed share token.") + + def validate(self, data: dict) -> dict: + logger.debug("Validating share token") + token = data["token"] + token_hash = hashlib.sha256(token.encode()).hexdigest() + try: + data["share_token"] = ShareToken.objects.get(token_hash=token_hash) + except ShareToken.DoesNotExist as exc: + raise serializers.ValidationError("Invalid or expired token") from exc + try: + data["feed_params"] = signing.loads(token, salt=SHARE_TOKEN_SALT, max_age=SHARE_TOKEN_MAX_AGE) + except signing.BadSignature as exc: + raise serializers.ValidationError("Invalid or expired token") from exc + return data + + +class TokenConsumeRequestSerializer(TokenRequestSerializer): + """Consume additionally rejects revoked tokens (a revoked link must not return data).""" + + def validate(self, data: dict) -> dict: + data = super().validate(data) + if data["share_token"].revoked: + raise serializers.ValidationError("Token has been revoked") + return data + + +### RESPONSES ### +class ASNFeedSerializer(serializers.Serializer): + """Response for the AS endpoint with aggregated IOC data.""" + + asn = serializers.IntegerField(min_value=1) + as_name = serializers.CharField(max_length=256, allow_blank=True) + ioc_count = serializers.IntegerField(min_value=0) + total_attack_count = serializers.IntegerField(min_value=0) + total_interaction_count = serializers.IntegerField(min_value=0) + total_login_attempts = serializers.IntegerField(min_value=0) + expected_ioc_count = serializers.FloatField(min_value=0) + expected_interactions = serializers.FloatField(min_value=0) + first_seen = serializers.DateTimeField() + last_seen = serializers.DateTimeField() + honeypots = serializers.ListField(child=serializers.CharField(max_length=120)) + + +class TrendingAttackerSerializer(serializers.Serializer): + attacker_ip = serializers.IPAddressField() + current_interactions = serializers.IntegerField(min_value=0) + previous_interactions = serializers.IntegerField(min_value=0) + interaction_delta = serializers.IntegerField() + growth_score = serializers.FloatField() + current_rank = serializers.IntegerField(min_value=1, allow_null=True) + previous_rank = serializers.IntegerField(min_value=1, allow_null=True) + rank_delta = serializers.IntegerField(allow_null=True) + + +class TrendingWindowSerializer(serializers.Serializer): + start = serializers.DateTimeField() + end = serializers.DateTimeField() + + +class TrendingFeedResponseSerializer(serializers.Serializer): + window_minutes = serializers.IntegerField(min_value=60) + feed_type = serializers.ListField(child=serializers.CharField(max_length=120)) + current_window = TrendingWindowSerializer() + previous_window = TrendingWindowSerializer() + count = serializers.IntegerField(min_value=0) + data_source = serializers.CharField(max_length=32) + attackers = TrendingAttackerSerializer(many=True) + + +class ShareTokenResponseSerializer(serializers.Serializer): + """Response for the share endpoint: the public consume and revoke URLs.""" + + url = serializers.URLField(help_text="Public URL that consumes the shared feed.") + revoke_url = serializers.URLField(help_text="URL that revokes the share token.") + + +class ShareTokenListItemSerializer(serializers.Serializer): + """One row of the caller's share-token list (metadata only, no token value).""" + + hash_prefix = serializers.SerializerMethodField(help_text="First 12 characters of the token hash.") + reason = serializers.CharField(allow_blank=True) + created_at = serializers.DateTimeField() + revoked = serializers.BooleanField() + revoked_at = serializers.DateTimeField(allow_null=True) + + def get_hash_prefix(self, obj) -> str: + return obj["token_hash"][:12] + + +""" +== Serializers for feed response data structure. == +NOTE: The serializers below are currently NOT used in production code (as of #629). +It has been kept in the codebase for the following reasons: + +1. **Documentation**: Serves as a clear schema definition for the API response contract +2. **Testing**: Validates the expected response structure through unit tests +3. **Future-proofing**: Allows easy re-enabling of validation if security requirements change +4. **Reference**: Useful for API consumers and developers to understand the response format + +Performance Optimization Context: +Previously, this serializer was instantiated and validated for each IOC in the response +(up to 5000 times per request), causing significant overhead (~1.8s for 5000 IOCs). +The optimization removed this per-item validation since the data is constructed internally +in api/views/utils.py (build_ioc_json_list / build_feed_dict) and guaranteed to match this schema. + +The response is now built directly without serializer validation, reducing response time +to ~0.03s (50-90x speedup) while maintaining the exact same API contract defined here. + +See: #629 for benchmarking details and discussion. +""" + + +class SimpleFeedResponseSerializer(serializers.Serializer): + feed_type = serializers.ListField(child=serializers.CharField(max_length=120)) + value = serializers.CharField(max_length=256) + scanner = serializers.BooleanField() + payload_request = serializers.BooleanField() + first_seen = serializers.DateField(format="%Y-%m-%d") + last_seen = serializers.DateField(format="%Y-%m-%d") + attack_count = serializers.IntegerField(min_value=1) + interaction_count = serializers.IntegerField(min_value=1) + ip_reputation = serializers.CharField(allow_blank=True, max_length=32) + asn = serializers.IntegerField(allow_null=True, min_value=1) + destination_port_count = serializers.IntegerField(min_value=0) + login_attempts = serializers.IntegerField(min_value=0) + recurrence_probability = serializers.FloatField(min_value=0, max_value=1) + expected_interactions = serializers.FloatField(min_value=0) + attacker_country = serializers.CharField(allow_null=True, allow_blank=True, max_length=120) + attacker_country_code = serializers.CharField(allow_null=True, allow_blank=True, max_length=2) + tags = TagSerializer(many=True, required=False, default=list) + + +class AdvancedFeedResponseSerializer(SimpleFeedResponseSerializer): + credential_count = serializers.IntegerField(min_value=0) + sensors = SensorSerializer(many=True, required=False, default=list) + firehol_categories = serializers.ListField(child=serializers.CharField(max_length=64), allow_empty=True, required=False) + destination_ports = serializers.ListField(child=serializers.IntegerField(min_value=1, max_value=65535), required=False) + days_seen = serializers.ListField(child=serializers.DateField(format="%Y-%m-%d"), required=False) + + +class BaseFeedEnvelopeSerializer(serializers.Serializer): + license = serializers.CharField(required=False, help_text="Feed license text, present when configured.") + + +class SimpleFeedEnvelopeSerializer(BaseFeedEnvelopeSerializer): + iocs = SimpleFeedResponseSerializer(many=True) + + +class PaginatedSimpleFeedSerializer(serializers.Serializer): + count = serializers.IntegerField(help_text="Total number of IOCs across all pages.") + total_pages = serializers.IntegerField(help_text="Total number of pages.") + results = SimpleFeedEnvelopeSerializer() + + +class AdvancedFeedEnvelopeSerializer(BaseFeedEnvelopeSerializer): + iocs = AdvancedFeedResponseSerializer(many=True) diff --git a/api/serializers/health.py b/api/serializers/health.py new file mode 100644 index 000000000..dc4ba46f8 --- /dev/null +++ b/api/serializers/health.py @@ -0,0 +1,54 @@ +from rest_framework import serializers + +DATABASE_STATES = ["up", "down", "degraded"] +QCLUSTER_STATES = ["up", "idle", "down", "unknown"] +ELASTICSEARCH_STATES = ["up", "down", "not configured", "unknown"] + + +class SystemStatusSerializer(serializers.Serializer): + uptime_seconds = serializers.IntegerField(read_only=True, help_text="Seconds elapsed since the application started.") + database = serializers.ChoiceField(choices=DATABASE_STATES, read_only=True, help_text="`degraded` means the database answers but the aggregation failed.") + qcluster = serializers.ChoiceField( + choices=QCLUSTER_STATES, read_only=True, help_text="`idle` means jobs are scheduled but none ran in the last 10 minutes." + ) + elasticsearch = serializers.ChoiceField(choices=ELASTICSEARCH_STATES, read_only=True, help_text="`not configured` means no Elasticsearch client is set up.") + + +class IocCountsSerializer(serializers.Serializer): + total = serializers.IntegerField(help_text="All IOCs on record.") + new_last_24h = serializers.IntegerField(help_text="IOCs first seen in the last 24 hours.") + + +class SessionCountsSerializer(serializers.Serializer): + total = serializers.IntegerField(help_text="All Cowrie sessions on record.") + last_24h = serializers.IntegerField(help_text="Cowrie sessions started in the last 24 hours.") + + +class HoneypotCountsSerializer(serializers.Serializer): + total = serializers.IntegerField(help_text="Configured honeypots.") + active = serializers.IntegerField(help_text="Honeypots currently marked active.") + + +class ThreatListCountsSerializer(serializers.Serializer): + firehol = serializers.IntegerField(help_text="Entries pulled from the FireHol lists.") + mass_scanners = serializers.IntegerField(help_text="Known mass scanners on record.") + tor_exit_nodes = serializers.IntegerField(help_text="Known Tor exit nodes on record.") + + +class JobCountsSerializer(serializers.Serializer): + scheduled = serializers.IntegerField(help_text="Django-Q schedules currently registered.") + failed_last_24h = serializers.IntegerField(help_text="Jobs that failed in the last 24 hours.") + successful_last_24h = serializers.IntegerField(help_text="Jobs that succeeded in the last 24 hours.") + + +class OverviewSerializer(serializers.Serializer): + iocs = IocCountsSerializer(required=False) + sessions = SessionCountsSerializer(required=False) + honeypots = HoneypotCountsSerializer(required=False) + threat_lists = ThreatListCountsSerializer(required=False) + jobs = JobCountsSerializer(required=False) + + +class HealthSerializer(serializers.Serializer): + system = SystemStatusSerializer(read_only=True) + overview = OverviewSerializer(read_only=True, help_text="Empty when the database is down.") diff --git a/api/serializers/payloads.py b/api/serializers/payloads.py new file mode 100644 index 000000000..0b2b658d9 --- /dev/null +++ b/api/serializers/payloads.py @@ -0,0 +1,34 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +from rest_framework import serializers + +from greedybear.models import HoneypotPayload + + +class HoneypotPayloadSerializer(serializers.ModelSerializer): + source_honeypots = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field="name", + help_text="Names of the honeypots that captured this payload.", + ) + + class Meta: + model = HoneypotPayload + fields = [ + "id", + "sha256", + "md5", + "sha1", + "mime_type", + "size", + "source_honeypots", + ] + extra_kwargs = { + "id": {"help_text": "Unique identifier of the payload."}, + "sha256": {"help_text": "SHA256 hash of the payload."}, + "md5": {"help_text": "MD5 hash of the payload."}, + "sha1": {"help_text": "SHA1 hash of the payload."}, + "mime_type": {"help_text": "MIME type of the payload file."}, + "size": {"help_text": "Size of the payload in bytes."}, + } diff --git a/api/serializers/utils.py b/api/serializers/utils.py new file mode 100644 index 000000000..9328160d7 --- /dev/null +++ b/api/serializers/utils.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from greedybear.models import Honeypot + + +class PresenceFlagField(serializers.BooleanField): + """BooleanField for presence-flag query params. + Makes sure that a valueless query param such as include_mass_scanners + is treated as truthy. + """ + + TRUE_VALUES = serializers.BooleanField.TRUE_VALUES | {""} + + +def feed_type_as_list(feed_type_str: str) -> list: + """Split a comma-separated feed type string into a stripped list of individual feed types. + + Args: + feed_type_str (str): Comma-separated feed type string (e.g. "cowrie,adbhoney"). + + Returns: + list[str]: List of non-empty, stripped feed type tokens. + """ + return [ft.strip() for ft in feed_type_str.split(",") if ft.strip()] + + +def get_valid_feed_types() -> frozenset[str]: + """ + Retrieve all valid feed types, combining predefined types with active general honeypot names. + + Returns: + frozenset[str]: An immutable set of valid feed type strings + """ + honeypots = Honeypot.objects.filter(active=True) + feed_types = ["all"] + [hp.name.lower() for hp in honeypots] + return frozenset(feed_types) diff --git a/api/urls.py b/api/urls.py index 34c15dfb6..4be3b2c35 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,46 +1,75 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from rest_framework import routers from api.views import ( + AdvancedFeedView, + AsnFeedView, + BatchStatusView, + ConsumeFeedView, + CowrieSessionView, + DashboardConfigView, + EnrichmentView, + EventsCreateView, + HealthView, + HoneypotPayloadViewSet, + HoneypotView, + PaginatedFeedView, + SensorCreateView, + ShareTokenViewSet, + SimpleFeedView, StatisticsViewSet, + TrendingFeedView, command_sequence_view, - cowrie_session_view, - enrichment_view, - feeds, - feeds_advanced, - feeds_asn, - feeds_consume, - feeds_pagination, - feeds_revoke, - feeds_share, - feeds_tokens, - general_honeypot_list, - health_view, news_view, ) # Routers provide an easy way of automatically determining the URL conf. +# These will appear in the generated schema +documented_router = routers.DefaultRouter(trailing_slash=False) +documented_router.register(r"payloads", HoneypotPayloadViewSet, basename="payloads") + +# These will NOT appear in the generated schema router = routers.DefaultRouter(trailing_slash=False) router.register(r"statistics", StatisticsViewSet, basename="statistics") +router.register(r"payloads", HoneypotPayloadViewSet, basename="payloads") + +# These come after /api/ +# and will appear in the generated schema +documented_urlpatterns = [ + # Feeds + path("feeds///.", SimpleFeedView.as_view()), + path("feeds/", PaginatedFeedView.as_view()), + path("feeds/advanced/", AdvancedFeedView.as_view()), + path("feeds/asn/", AsnFeedView.as_view()), + path("feeds/trending/", TrendingFeedView.as_view()), + path("feeds/share", ShareTokenViewSet.as_view({"get": "share"})), + path("feeds/consume/", ConsumeFeedView.as_view()), + path("feeds/revoke/", ShareTokenViewSet.as_view({"get": "revoke"})), + path("feeds/tokens/", ShareTokenViewSet.as_view({"get": "list_tokens"})), + path("enrichment", EnrichmentView.as_view()), + path("cowrie_session", CowrieSessionView.as_view()), + path("honeypot/", HoneypotView.as_view()), + path("sensor/", SensorCreateView.as_view()), + path("events/add/", EventsCreateView.as_view()), + path("events/status//", BatchStatusView.as_view()), + path("health/", HealthView.as_view()), + path("dashboard-config/", DashboardConfigView.as_view()), +] +schema_urlconf = [path("api/", include(documented_urlpatterns + documented_router.urls))] -# These come after /api/.. +# These come after /api/ +# but won't appear in the generated schema urlpatterns = [ - path("feeds/", feeds_pagination), - path("feeds/share", feeds_share), - path("feeds/consume/", feeds_consume), - path("feeds/revoke/", feeds_revoke), - path("feeds/tokens/", feeds_tokens), - path("feeds/advanced/", feeds_advanced), - path("feeds/asn/", feeds_asn), - path("feeds///.", feeds), - path("enrichment", enrichment_view), - path("cowrie_session", cowrie_session_view), + # OpenAPI schema and interactive docs + path("schema/", SpectacularAPIView.as_view(urlconf=schema_urlconf), name="schema"), + path("schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("command_sequence", command_sequence_view), - path("general_honeypot", general_honeypot_list), + path("general_honeypot", HoneypotView.as_view()), path("news/", news_view), - path("health/", health_view), # router viewsets path("", include(router.urls)), # certego_saas: @@ -48,4 +77,5 @@ path("", include("certego_saas.urls")), # auth path("auth/", include("authentication.urls")), + *documented_urlpatterns, ] diff --git a/api/views/__init__.py b/api/views/__init__.py index 398982a0c..ab0e1c19d 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -1,8 +1,12 @@ from api.views.command_sequence import * from api.views.cowrie_session import * +from api.views.dashboard_config import * from api.views.enrichment import * +from api.views.event import * from api.views.feeds import * from api.views.health import * from api.views.honeypots import * from api.views.news import * +from api.views.payloads import * +from api.views.sensor import * from api.views.statistics import * diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index e91314b28..17a827fd9 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -1,135 +1,100 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. import ipaddress -import logging from certego_saas.apps.auth.backend import CookieTokenAuthentication from django.conf import settings -from django.http import Http404, HttpResponseBadRequest +from django.http import Http404 +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import status -from rest_framework.decorators import ( - api_view, - authentication_classes, - permission_classes, -) from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView -from api.views.utils import UnableToExtractSourceIPError, get_request_source_ip -from greedybear.consts import GET -from greedybear.models import CommandSequence, CowrieSession, Statistics, ViewType +from api.mixins import RequestLoggingMixin +from api.serializers import CowrieSessionRequestSerializer, CowrieSessionSerializer +from api.views.utils import save_request_source +from greedybear.models import CommandSequence, CowrieSession, ViewType from greedybear.utils import is_ip_address, is_sha256hash -logger = logging.getLogger(__name__) - - -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def cowrie_session_view(request): - """ - Retrieve Cowrie honeypot session data including command sequences, credentials, and session details. - Queries can be performed using an IP address to find all sessions from that source, - a SHA-256 hash to find sessions containing a specific command sequence, - or a password to find all sessions where that password was used. - - Args: - request: The HTTP request object containing query parameters - query (str, required): The search term, can be an IP address, the SHA-256 hash of a command sequence, - or a password. SHA-256 hashes should match command sequences generated using Python's "\\n".join(sequence) format. - include_similar (bool, optional): When "true", expands the result to include all sessions that executed - command sequences belonging to the same cluster(s) as command sequences found in the initial query result. - Requires CLUSTER_COWRIE_COMMAND_SEQUENCES enabled in configuration. Default: false - include_credentials (bool, optional): When "true", includes all credentials used across matching Cowrie sessions. - Default: false - include_session_data (bool, optional): When "true", includes detailed information about matching Cowrie sessions. - Default: false - - Returns: - Response (200): JSON object containing: - - query (str): The original query parameter - - commands (list[str]): Unique command sequences (newline-delimited strings) - - sources (list[str]): Unique source IP addresses - - credentials (list[str], optional): Unique credentials if include_credentials=true - - sessions (list[dict], optional): Session details if include_session_data=true - - time (datetime): Session start time - - duration (float): Session duration in seconds - - source (str): Source IP address - - interactions (int): Number of interactions in session - - credentials (list[str]): Credentials used in this session - - commands (str): Command sequence executed (newline-delimited) - Response (400): Bad Request - Missing or invalid query parameter - Response (404): Not Found - No matching sessions found - Response (500): Internal Server Error - Unexpected error occurred - - Example Queries: - /api/cowrie_session?query=1.2.3.4 - /api/cowrie_session?query=5120e94e366ec83a79ee80454e4d1c76c06499ab19032bcdc7f0b4523bdb37a6 - /api/cowrie_session?query=1.2.3.4&include_credentials=true&include_session_data=true&include_similar=true - /api/cowrie_session?query=admin123 - """ - observable = request.query_params.get("query") - include_similar = request.query_params.get("include_similar", "false").lower() == "true" - include_credentials = request.query_params.get("include_credentials", "false").lower() == "true" - include_session_data = request.query_params.get("include_session_data", "false").lower() == "true" - - logger.info(f"Cowrie view requested by {request.user} for {observable}") - - if not observable: - return HttpResponseBadRequest("Missing required 'query' parameter") - if is_ip_address(observable): - sessions = CowrieSession.objects.filter(source__name=observable, duration__gt=0).prefetch_related("source", "commands", "credentials") - if not sessions.exists(): - raise Http404(f"No information found for IP: {observable}") - - elif is_sha256hash(observable): - try: - commands = CommandSequence.objects.get(commands_hash=observable.lower()) - except CommandSequence.DoesNotExist as exc: - raise Http404(f"No command sequences found with hash: {observable}") from exc - sessions = CowrieSession.objects.filter(commands=commands, duration__gt=0).prefetch_related("source", "commands", "credentials") - else: - if len(observable) > 256: # max_length of Credential.password field - return HttpResponseBadRequest("Query exceeds maximum password length") - sessions = CowrieSession.objects.filter(credentials__password=observable, duration__gt=0).prefetch_related("source", "commands", "credentials") - if not sessions.exists(): - raise Http404(f"No information found for password: {observable}") - - try: - source_ip = get_request_source_ip(request) - Statistics(source=source_ip, view=ViewType.COWRIE_SESSION_VIEW.value).save() - except UnableToExtractSourceIPError: - logger.warning("Skipping statistics recording due to unable to extract source IP") - - if include_similar: - commands = {s.commands for s in sessions if s.commands} - clusters = {cmd.cluster for cmd in commands if cmd.cluster is not None} - related_sessions = CowrieSession.objects.filter(commands__cluster__in=clusters, duration__gt=0).prefetch_related("source", "commands", "credentials") - sessions = sessions.union(related_sessions) - - response_data = { - "query": observable, - } - if settings.FEEDS_LICENSE: - response_data["license"] = settings.FEEDS_LICENSE - - unique_commands = {s.commands for s in sessions if s.commands} - response_data["commands"] = sorted("\n".join(cmd.commands) for cmd in unique_commands) - response_data["sources"] = sorted({s.source.name for s in sessions}, key=lambda ip: ipaddress.ip_address(ip)) - if include_credentials: - response_data["credentials"] = sorted({str(c) for s in sessions for c in s.credentials.all()}) - if include_session_data: - response_data["sessions"] = [ - { - "time": s.start_time, - "duration": s.duration, - "source": s.source.name, - "interactions": s.interaction_count, - "credentials": [str(c) for c in s.credentials.all()], - "commands": "\n".join(s.commands.commands) if s.commands else "", - } - for s in sessions - ] - - return Response(response_data, status=status.HTTP_200_OK) +@extend_schema_view( + get=extend_schema( + tags=["Cowrie Session"], + summary="Session data from the Cowrie honeypot", + description=( + "Retrieve Cowrie honeypot session data including command sequences, credentials, and session details. " + "Queries can be performed using an IP address to find all sessions from that source, " + "a SHA-256 hash to find sessions containing a specific command sequence, " + "or a password to find all sessions where that password was used." + ), + parameters=[CowrieSessionRequestSerializer], + responses={ + 200: CowrieSessionSerializer, + 400: OpenApiResponse(description="Missing or invalid `query` parameter."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 404: OpenApiResponse(description="No matching sessions found."), + }, + ) +) +class CowrieSessionView(RequestLoggingMixin, APIView): + authentication_classes = [CookieTokenAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request: Request, *args, **kwargs): + request_serializer = CowrieSessionRequestSerializer(data=request.query_params.dict()) + request_serializer.is_valid(raise_exception=True) + save_request_source(request, ViewType.COWRIE_SESSION_VIEW.value) + + observable = request_serializer.validated_data["query"] + if is_ip_address(observable): + sessions = CowrieSession.objects.filter(source__name=observable, duration__gt=0).prefetch_related("source", "commands", "credentials") + if not sessions.exists(): + raise Http404(f"No information found for IP: {observable}") + + elif is_sha256hash(observable): + try: + commands = CommandSequence.objects.get(commands_hash=observable.lower()) + except CommandSequence.DoesNotExist as exc: + raise Http404(f"No command sequences found with hash: {observable}") from exc + sessions = CowrieSession.objects.filter(commands=commands, duration__gt=0).prefetch_related("source", "commands", "credentials") + else: + sessions = CowrieSession.objects.filter(credentials__password=observable, duration__gt=0).prefetch_related("source", "commands", "credentials") + if not sessions.exists(): + raise Http404(f"No information found for password: {observable}") + + if request_serializer.validated_data["include_similar"]: + commands = {s.commands for s in sessions if s.commands} + clusters = {cmd.cluster for cmd in commands if cmd.cluster is not None} + related_sessions = CowrieSession.objects.filter(commands__cluster__in=clusters, duration__gt=0).prefetch_related( + "source", "commands", "credentials" + ) + sessions = sessions.union(related_sessions) + + data = { + "query": observable, + } + if settings.FEEDS_LICENSE: + data["license"] = settings.FEEDS_LICENSE + + unique_commands = {s.commands for s in sessions if s.commands} + data["commands"] = sorted("\n".join(cmd.commands) for cmd in unique_commands) + data["sources"] = sorted({s.source.name for s in sessions}, key=lambda ip: ipaddress.ip_address(ip)) + if request_serializer.validated_data["include_credentials"]: + data["credentials"] = sorted({str(c) for s in sessions for c in s.credentials.all()}) + if request_serializer.validated_data["include_session_data"]: + data["sessions"] = [ + { + "time": s.start_time, + "duration": s.duration, + "source": s.source.name, + "interactions": s.interaction_count, + "credentials": [str(c) for c in s.credentials.all()], + "commands": "\n".join(s.commands.commands) if s.commands else "", + } + for s in sessions + ] + + response_serializer = CowrieSessionSerializer(data) + return Response(response_serializer.data, status=status.HTTP_200_OK) diff --git a/api/views/dashboard_config.py b/api/views/dashboard_config.py new file mode 100644 index 000000000..0840c159c --- /dev/null +++ b/api/views/dashboard_config.py @@ -0,0 +1,118 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +import logging + +from certego_saas.apps.auth.backend import CookieTokenAuthentication +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view +from rest_framework import serializers, status +from rest_framework.authentication import SessionAuthentication +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.permissions import IsSuperuserOrReadOnly +from greedybear.models import DashboardConfig + +logger = logging.getLogger(__name__) + + +class DashboardLayoutSerializer(serializers.Serializer): + layout = serializers.DictField( + help_text=( + "Saved dashboard layout containing 'widgetConfigs' (list) and 'layouts' (react-grid-layout breakpoint map). Null when no config has been saved yet." + ), + ) + + def validate_layout(self, value): + if "widgetConfigs" not in value or "layouts" not in value: + raise serializers.ValidationError("'layout' must contain 'widgetConfigs' and 'layouts' keys.") + if not isinstance(value["widgetConfigs"], list): + raise serializers.ValidationError("'widgetConfigs' must be a list.") + if not isinstance(value["layouts"], dict): + raise serializers.ValidationError("'layouts' must be an object.") + return value + + +@extend_schema_view( + get=extend_schema( + tags=["Dashboard"], + summary="Retrieve the global dashboard layout", + description=( + "Returns the dashboard layout saved by a superuser. " + "When no configuration has been saved yet, `layout` is `null` and the " + "frontend falls back to the built-in default layout. " + "Open to all users including anonymous visitors." + ), + responses={ + 200: DashboardLayoutSerializer, + }, + ), + put=extend_schema( + tags=["Dashboard"], + summary="Save the global dashboard layout", + description=( + "Replaces the globally shared dashboard layout. " + "The saved configuration is immediately visible to all users on their next page load. " + "Restricted to superusers." + ), + request=DashboardLayoutSerializer, + responses={ + 200: DashboardLayoutSerializer, + 400: OpenApiResponse(description="Invalid request body - missing or malformed 'layout' key."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 403: OpenApiResponse(description="Permission denied - requires superuser privileges."), + }, + ), + delete=extend_schema( + tags=["Dashboard"], + summary="Reset the global dashboard layout to defaults", + description=( + "Deletes the saved dashboard configuration. " + "All users will fall back to the built-in default layout on their next page load. " + "Restricted to superusers." + ), + responses={ + 204: OpenApiResponse(description="Configuration deleted successfully."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 403: OpenApiResponse(description="Permission denied - requires superuser privileges."), + }, + ), +) +class DashboardConfigView(APIView): + authentication_classes = [CookieTokenAuthentication, SessionAuthentication] + permission_classes = [IsSuperuserOrReadOnly] + + def get(self, request: Request) -> Response: + record = DashboardConfig.objects.first() + if record is None: + return Response({"layout": None}, status=status.HTTP_200_OK) + return Response({"layout": record.layout}, status=status.HTTP_200_OK) + + def put(self, request: Request) -> Response: + serializer = DashboardLayoutSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + layout = serializer.validated_data["layout"] + + record = DashboardConfig.objects.first() + if record is None: + record = DashboardConfig(layout=layout, updated_by=request.user) + else: + record.layout = layout + record.updated_by = request.user + record.save() + + logger.info( + "DashboardConfig saved by superuser=%s (record id=%s)", + request.user, + record.pk, + ) + return Response({"layout": record.layout}, status=status.HTTP_200_OK) + + def delete(self, request: Request) -> Response: + deleted_count, _ = DashboardConfig.objects.all().delete() + logger.info( + "DashboardConfig deleted by superuser=%s (rows removed: %s)", + request.user, + deleted_count, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/api/views/enrichment.py b/api/views/enrichment.py index 2269f76d2..1b89d1e96 100644 --- a/api/views/enrichment.py +++ b/api/views/enrichment.py @@ -1,49 +1,57 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. -import logging from certego_saas.apps.auth.backend import CookieTokenAuthentication +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import status -from rest_framework.decorators import ( - api_view, - authentication_classes, - permission_classes, -) from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response - -from api.serializers import EnrichmentSerializer -from api.views.utils import UnableToExtractSourceIPError, get_request_source_ip -from greedybear.consts import GET -from greedybear.models import Statistics, ViewType - -logger = logging.getLogger(__name__) - - -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def enrichment_view(request): - """ - Handle enrichment requests for a specific observable (domain or IP address). - - Args: - request: The incoming request object containing query parameters. - - Returns: - Response: A JSON response indicating whether the observable was found, - and if so, the corresponding IOC. - """ - observable_name = request.query_params.get("query") - logger.info(f"Enrichment view requested for: {observable_name}") - serializer = EnrichmentSerializer(data=request.query_params, context={"request": request}) - serializer.is_valid(raise_exception=True) - - try: - source_ip = get_request_source_ip(request) - request_source = Statistics(source=source_ip, view=ViewType.ENRICHMENT_VIEW.value) - request_source.save() - except UnableToExtractSourceIPError: - logger.warning("Skipping statistics recording due to unable to extract source IP") - - return Response(serializer.data, status=status.HTTP_200_OK) +from rest_framework.views import APIView + +from api.mixins import RequestLoggingMixin +from api.serializers import EnrichmentRequestSerializer, EnrichmentSerializer +from api.views.utils import save_request_source +from greedybear.models import IOC, ViewType + + +@extend_schema_view( + get=extend_schema( + tags=["Enrichment"], + summary="Enrich a single observable", + description=( + "Look up an IP address or domain in the IOC database. " + "A well-formed observable always returns 200: `found` states whether GreedyBear knows it and `ioc` carries the full record when it does." + ), + parameters=[EnrichmentRequestSerializer], + responses={ + 200: EnrichmentSerializer, + 400: OpenApiResponse(description="The `query` parameter is missing or is not a valid IP address or domain."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + }, + ) +) +class EnrichmentView(RequestLoggingMixin, APIView): + authentication_classes = [CookieTokenAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request: Request, *args, **kwargs): + request_serializer = EnrichmentRequestSerializer(data=request.query_params.dict()) + request_serializer.is_valid(raise_exception=True) + save_request_source(request, ViewType.ENRICHMENT_VIEW.value) + + query = request_serializer.validated_data["query"] + try: + data = { + "found": True, + "ioc": IOC.objects.prefetch_related("tags", "sensors").get(name=query), + "query": query, + } + except IOC.DoesNotExist: + data = { + "found": False, + "ioc": None, + "query": query, + } + response_serializer = EnrichmentSerializer(data) + return Response(response_serializer.data, status=status.HTTP_200_OK) diff --git a/api/views/event.py b/api/views/event.py new file mode 100644 index 000000000..c32d5e60d --- /dev/null +++ b/api/views/event.py @@ -0,0 +1,149 @@ +import logging + +from certego_saas.apps.auth.backend import CookieTokenAuthentication +from django_q.tasks import async_task +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.mixins import RequestLoggingMixin +from api.serializers import BatchStatusRequestSerializer, BatchStatusSerializer, InjectionResponseSerializer, InjectionSerializer +from api.views.utils import create_batch_and_events, increment_and_evaluate_lock, resolve_active_api_source +from greedybear.models import EventStatus, EventStatusType + +logger = logging.getLogger(__name__) + + +class EventsCreateView(RequestLoggingMixin, APIView): + authentication_classes = [CookieTokenAuthentication] + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Event Injection"], + summary="Ingest a batch of events.", + description=( + "Ingest a batch of raw security events, persist them, and hand off processing to a background task. " + "This endpoint validates the payload structure, maps events to a tracking batch, saves them bulk-style to the database to minimize I/O overhead, " + "and offloads heavy parsing (extracting IOCs, usernames, commands, etc.) asynchronously via Django-Q. " + "Note: Users calling this endpoint must have an active, associated `APISource`." + ), + request=InjectionSerializer, + responses={ + 202: OpenApiResponse(response=InjectionResponseSerializer, description="Payload verified and successfully queued for background extraction."), + 400: OpenApiResponse(description="Validation failure or empty event set creation."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 403: OpenApiResponse(description="Missing `APISource` or locked account state."), + 500: OpenApiResponse(description="An internal error occurred during early event processing."), + }, + ) + def post(self, request: Request, *args, **kwargs): + api_source, error_response = resolve_active_api_source(request) + if error_response: + return error_response + + serializer = InjectionSerializer(data=request.data) + if not serializer.is_valid(): + if lock_response := increment_and_evaluate_lock(api_source): + return lock_response + return Response({"error": "Invalid data", "details": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + events_data = serializer.validated_data["events"] + + try: + batch, total_created = create_batch_and_events( + events_data, + api_source, + ) + except ValueError as e: + if lock_response := increment_and_evaluate_lock(api_source): + return lock_response + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception: + logger.exception("Failed while creating batch & events") + return Response({"error": "An internal database error occurred while staging events"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + try: + # enqueue background task + async_task( + "greedybear.process_event.process_incoming_event", + api_source.id, + batch.task_id, + ) + except Exception as e: + logger.exception(f"Failed to enqueue background task for batch {batch.task_id}") + + # marking the batch as failed so it doesn't get orphaned in a 'PENDING' state + batch.status = EventStatusType.FAILED + batch.last_error = f"Background task dispatch failed: {e!s}" + batch.save(update_fields=["status", "last_error"]) + + return Response({"error": "An internal error occurred while queueing events for processing"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + logger.info(f"[task={batch.task_id}] Accepted {total_created} events — source={api_source.name}") + + response_serializer = InjectionResponseSerializer( + { + "message": f"{total_created} events accepted for processing", + "task_id": batch.task_id, + "status_url": f"/api/events/status/{batch.task_id}/", + } + ) + + return Response( + response_serializer.data, + status=status.HTTP_202_ACCEPTED, + ) + + +class BatchStatusView(RequestLoggingMixin, APIView): + authentication_classes = [CookieTokenAuthentication] + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Event Injection"], + summary="Retrieve the status of a specific event batch", + description=( + "Retrieve the execution and processing status of a specific event batch using its task ID. " + "This endpoint safely exposes the processing lifecycle phase (e.g., pending, processing, completed, failed) " + "of an asynchronous background extraction task triggered by Django-Q. " + "Note: Users calling this endpoint must have an active, associated `APISource`." + ), + parameters=[ + OpenApiParameter( + name="task_id", + type=str, + pattern=r"^[0-9a-f]{32}$", + location=OpenApiParameter.PATH, + description="The unique string identifier assigned to the background processing job.", + ) + ], + responses={ + 200: OpenApiResponse(response=BatchStatusSerializer, description="Success payload detailing batch metrics, failure reasons (if any), and state."), + 400: OpenApiResponse(description="`task_id` is not a well-formed batch identifier."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 403: OpenApiResponse(description="Missing `APISource` or locked account state."), + 404: OpenApiResponse(description="No event batch matching the provided `task_id` exists for this account."), + }, + ) + def get(self, request: Request, task_id: str, *args, **kwargs): + api_source, error_response = resolve_active_api_source(request) + if error_response: + return error_response + + # task_id arrives as a path segment, so it is wrapped into a mapping to be validated. + request_serializer = BatchStatusRequestSerializer(data={"task_id": task_id}) + request_serializer.is_valid(raise_exception=True) + + try: + batch = EventStatus.objects.get(task_id=task_id, api_source=api_source) + except EventStatus.DoesNotExist: + return Response({"error": f"No batch found for task_id={task_id}"}, status=status.HTTP_404_NOT_FOUND) + + return Response( + BatchStatusSerializer(batch).data, + status=status.HTTP_200_OK, + ) diff --git a/api/views/feeds.py b/api/views/feeds.py index 7756a801f..8a706ec69 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -1,373 +1,537 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. import hashlib -import logging +import json +from datetime import timedelta from certego_saas.apps.auth.backend import CookieTokenAuthentication from certego_saas.ext.pagination import CustomPageNumberPagination +from django.contrib.postgres.aggregates import ArrayAgg from django.core import signing +from django.db.models import Count, F, Q, QuerySet, Value +from django.db.models.functions import JSONObject +from django.http import HttpResponseBase, StreamingHttpResponse from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view from rest_framework import status -from rest_framework.decorators import ( - api_view, - authentication_classes, - permission_classes, - throttle_classes, -) -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response - -from api.serializers import ASNFeedsOrderingSerializer +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSet + +from api.filters import FeedsFilterSet +from api.mixins import CachedResponseMixin, RequestLoggingMixin +from api.renderers import FeedCSVRenderer, FeedJSONRenderer, FeedNDJSONRenderer, FeedTextRenderer, Stix21Renderer +from api.serializers import ( + AdvancedFeedEnvelopeSerializer, + AdvancedFeedRequestSerializer, + ASNFeedRequestSerializer, + ASNFeedSerializer, + PaginatedSimpleFeedSerializer, + ShareFeedRequestSerializer, + ShareTokenListItemSerializer, + ShareTokenResponseSerializer, + SimpleFeedEnvelopeSerializer, + SimpleFeedRequestSerializer, + TokenConsumeRequestSerializer, + TokenRequestSerializer, + TrendingFeedRequestSerializer, + TrendingFeedResponseSerializer, +) from api.throttles import FeedsAdvancedThrottle, FeedsThrottle, SharedFeedRateThrottle from api.views.utils import ( - FeedRequestParams, - asn_aggregated_queryset, - feeds_response, - get_queryset, - get_valid_feed_types, + aggregate_iocs_by_asn, + build_feed_dict, + save_request_source, + stream_ioc_objects, ) -from greedybear.consts import GET -from greedybear.models import ShareToken - -logger = logging.getLogger(__name__) - -ALLOWED_UNAUTHENTICATED_QUERY_PARAMS = [ - "feed_type", - "attack_type", - "ioc_type", - "ordering", - "include_mass_scanners", - "include_tor_exit_nodes", - "prioritize", -] - -_TOKEN_LIST_FIELDS = ( - "token_hash", - "reason", - "created_at", - "revoked", - "revoked_at", +from greedybear.consts import SHARE_TOKEN_SALT, TRENDING_FEEDS_DATA_VERSION_KEY +from greedybear.cronjobs.repositories import TrendingBucketRepository +from greedybear.cronjobs.trending import build_ranked_attackers +from greedybear.models import IOC, ShareToken, ViewType + +RENDERERS_BY_FORMAT = { + "json": FeedJSONRenderer, + "txt": FeedTextRenderer, + "csv": FeedCSVRenderer, + "stix21": Stix21Renderer, + "ndjson": FeedNDJSONRenderer, +} + +RESPONSES = { + 400: OpenApiResponse(description="Invalid feed parameters."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 429: OpenApiResponse(description="Rate limit exceeded."), +} + +PAGE_PARAMETER = OpenApiParameter( + "page", + OpenApiTypes.INT, + OpenApiParameter.QUERY, + description="1-based page number. Only meaningful when the response is paginated.", ) -@api_view([GET]) -@throttle_classes([FeedsThrottle]) -def feeds(request, feed_type, attack_type, prioritize, format_): - """ - Handle requests for IOC feeds with specific parameters and format the response accordingly. - - Args: - request: The incoming request object. - feed_type (str): Type of feed (e.g. cowrie, honeytrap, etc.). - attack_type (str): Type of attack (e.g., all, specific attack types). - prioritize (str): Prioritization mechanism to use (e.g., recent, persistent). - format_ (str): Desired format of the response (e.g., json, csv, txt). - include_mass_scanners (bool): query parameter flag to include IOCs that are known mass scanners. - include_tor_exit_nodes (bool): query parameter flag to include IOCs that are known tor exit nodes. - - Returns: - Response: The HTTP response with formatted IOC data. - """ - logger.info(f"request /api/feeds with params: feed type: {feed_type}, attack_type: {attack_type}, prioritization: {prioritize}, format: {format_}") - - filtered_query_params = {key: request.query_params.get(key) for key in ALLOWED_UNAUTHENTICATED_QUERY_PARAMS if key in request.query_params} - - feed_params_data = filtered_query_params.copy() - feed_params_data.update({"feed_type": feed_type, "attack_type": attack_type, "format": format_}) - feed_params = FeedRequestParams(feed_params_data) - feed_params.apply_default_filters(filtered_query_params) - feed_params.set_prioritization(prioritize) +class BaseFeedView(RequestLoggingMixin, CachedResponseMixin, APIView): + """Shared GET flow: + validate request params, build the IOC queryset and render (paginating when asked). - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) - return feeds_response(request, iocs_queryset, feed_params, valid_feed_types) + Subclasses represent the actual endpoints and are typically attribute-only. + They set the usual DRF class attributes and the feed-specific toggles + overriding the defaults below. - -@api_view([GET]) -@throttle_classes([FeedsThrottle]) -def feeds_pagination(request): - """ - Handle requests for paginated IOC feeds based on query parameters. - - Args: - request: The incoming request object. - - Returns: - Response: The paginated HTTP response with IOC data. + Responses are cached via CachedResponseMixin + The extraction pipeline invalidates them on every run. """ - logger.info(f"request /api/feeds with params: {request.query_params}") - - filtered_query_params = {key: request.query_params.get(key) for key in ALLOWED_UNAUTHENTICATED_QUERY_PARAMS if key in request.query_params} - - feed_params = FeedRequestParams(filtered_query_params) - feed_params.format = "json" - feed_params.apply_default_filters(filtered_query_params) - feed_params.set_prioritization(filtered_query_params.get("prioritize")) - - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) - paginator = CustomPageNumberPagination() - iocs = paginator.paginate_queryset(iocs_queryset, request) - resp_data = feeds_response(request, iocs, feed_params, valid_feed_types, dict_only=True) - return paginator.get_paginated_response(resp_data) - - -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -@throttle_classes([FeedsAdvancedThrottle]) -def feeds_advanced(request): - """ - Handle requests for IOC feeds based on query parameters and format the response accordingly. - - Args: - request: The incoming request object. - feed_type (str): Type of feed to retrieve. (supported: `cowrie`, `honeytrap`, etc.; default: `all`) - attack_type (str): Type of attack to filter. (supported: `scanner`, `payload_request`, `all`; default: `all`) - max_age (int): Maximum number of days since last occurrence. E.g. an IOC that was last seen 4 days ago is excluded by default. (default: 3) - min_days_seen (int): Minimum number of days on which an IOC must have been seen. (default: 1) - min_credential_count (int, optional): Filter IOCs with at least this many distinct credentials. (default: no filter) - max_credential_count (int, optional): Filter IOCs with at most this many distinct credentials. (default: no filter) - include_reputation (str): `;`-separated list of reputation values to include, e.g. `known attacker` or `known attacker;` to include IOCs without reputation. (default: include all) - exclude_reputation (str): `;`-separated list of reputation values to exclude, e.g. `mass scanner` or `mass scanner;bot, crawler`. (default: exclude none) - feed_size (int): Number of IOC items to return. (default: 5000) - ordering (str): Field to order results by, with optional `-` prefix for descending. (default: `-last_seen`) - verbose (bool): `true` to include IOC properties that contain a lot of data, e.g. the list of days it was seen. (default: `false`) - paginate (bool): `true` to paginate results. This forces the json format. (default: `false`) - format (str): Response format type. Besides `json`, `txt` and `csv` are supported but the response will only contain IOC values (e.g. IP addresses) without further information. (default: `json`) - tag_key (str, optional): Filter IOCs by tag key, e.g. `malware` or `confidence_of_abuse`. Only IOCs with at least one matching tag are returned. - tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring match), e.g. `mirai`. Can be used alone or combined with `tag_key`. - - Returns: - Response: The HTTP response with formatted IOC data. - """ - logger.info(f"request /api/feeds/advanced/ with params: {request.query_params}") - feed_params = FeedRequestParams(request.query_params) - verbose = feed_params.verbose == "true" - paginate = feed_params.paginate == "true" - if paginate: - feed_params.format = "json" - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset( - request, - feed_params, - valid_feed_types, - tag_key=request.query_params.get("tag_key", "").strip(), - tag_value=request.query_params.get("tag_value", "").strip(), - include_sensors=True, - include_credential_count=True, + # ACCESS CONTROL + authentication_classes = [CookieTokenAuthentication] + permission_classes = [AllowAny] + throttle_classes = [FeedsThrottle] + renderer_classes = [FeedJSONRenderer, FeedTextRenderer, FeedCSVRenderer, Stix21Renderer, FeedNDJSONRenderer] + + # REQUEST HANDLING + serializer_class = None + pagination_class = None + + # QUERYSET SHAPE + include_sensors = False + is_aggregated = False + + # VALIDATED REQUEST PARAMETERS - populated in get() + request_params = None + + # OUTPUT SHAPE - set dynamically, depending on the requested format + build_feed_envelope = False + + # RESPONSE CACHING - do not override in subclasses + cache_namespace = "feeds" + + def get_request_data(self, request, **kwargs) -> dict: + """Raw input mapping handed to the serializer. + Defaults to the query params. + Override to merge path parameters or token data.""" + return request.query_params.dict() + + def validate_request(self, request: Request, **kwargs) -> dict: + """Run the request data through a serializer and return the validated params, + raising ValidationError (HTTP 400) on bad input.""" + serializer = self.serializer_class(data=self.get_request_data(request, **kwargs)) + serializer.is_valid(raise_exception=True) + return serializer.validated_data + + def should_paginate(self, request_data: dict) -> bool: + """Whether to paginate this response. + Requires a pagination_class and the validated paginate flag.""" + return self.pagination_class is not None and request_data.get("paginate", False) + + def get_renderer_context(self) -> dict: + """Publish the render-time flags the feed renderers need, so they read + explicit context keys instead of reaching into view internals.""" + context = super().get_renderer_context() + context["verbose"] = (self.request_params or {}).get("verbose", False) + context["include_sensors"] = self.include_sensors + context["build_feed_envelope"] = self.build_feed_envelope + return context + + def get_queryset(self) -> QuerySet: + """Build the IOC queryset from the validated request parameters.""" + iocs = IOC.objects.annotate(value=F("name")) + iocs = FeedsFilterSet(self.request_params, queryset=iocs, request=self.request).qs + + iocs = iocs.exclude(ip_reputation__in=self.request_params.get("exclude_reputation", [])).distinct() + + if "all" not in self.request_params["feed_type"]: + type_filter = Q() + for ft in self.request_params["feed_type"]: + type_filter |= Q(honeypots__name__iexact=ft) + iocs = iocs.filter(type_filter) + + if self.is_aggregated: + return iocs + + iocs = iocs.filter(honeypots__active=True) + iocs = iocs.annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True)) + if self.request_params["format"] in ["json", "ndjson"]: + iocs = iocs.annotate( + tags_json=ArrayAgg( + JSONObject(key=F("tags__key"), value=F("tags__value"), source=F("tags__source")), + filter=Q(tags__isnull=False), + default=Value([]), + distinct=True, + ) + ) + return iocs + + def sort_and_slice_queryset(self, qs: QuerySet) -> QuerySet: + """Apply the requested ordering and cap the result at feed_size. + Aggregated views are returned untouched.""" + if self.is_aggregated: + return qs + return qs.order_by(self.request_params["ordering"])[: self.request_params["feed_size"]] + + def render_response(self, request: Request, iocs_queryset: QuerySet) -> HttpResponseBase: + """Select the renderer for the validated format and hand it the prepared data.""" + requested_format = self.request_params.get("format") + if self.should_paginate(self.request_params): + verbose = self.request_params.get("verbose", False) + paginator = self.pagination_class() + page = paginator.paginate_queryset(iocs_queryset, request) + resp_data = build_feed_dict(page, verbose=verbose, include_sensors=self.include_sensors) + request.accepted_renderer = FeedJSONRenderer() + request.accepted_media_type = FeedJSONRenderer.media_type + return paginator.get_paginated_response(resp_data) + if requested_format == "ndjson" or request.accepted_media_type == "application/x-ndjson": + verbose = self.request_params.get("verbose", False) + + def stream_iocs(): + for ioc in stream_ioc_objects(iocs_queryset, verbose=verbose, include_sensors=self.include_sensors): + yield json.dumps(ioc, default=str) + "\n" + + response = StreamingHttpResponse(stream_iocs(), content_type="application/x-ndjson") + # tell nginx not to buffer this response so rows reach the client as they are produced + response["X-Accel-Buffering"] = "no" + return response + renderer = RENDERERS_BY_FORMAT[self.request_params["format"]]() + request.accepted_renderer = renderer + request.accepted_media_type = renderer.media_type + self.build_feed_envelope = True + return Response(iocs_queryset) + + def get(self, request: Request, *args, **kwargs) -> HttpResponseBase: + """Validate the request, build and sort the IOC queryset, + render it in the requested format, and optionally paginate.""" + self.request_params = self.validate_request(request, **kwargs) + save_request_source(request, ViewType.FEEDS_VIEW.value) + cached_response = self.get_cached_response() + if cached_response is not None: + return cached_response + iocs_queryset = self.get_queryset() + iocs_queryset = self.sort_and_slice_queryset(iocs_queryset) + return self.render_response(request, iocs_queryset) + + +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Public feed (path-parameter form)", + description="Public threat feed addressed through the URL path.", + auth=[], + parameters=[ + SimpleFeedRequestSerializer, + # drop the URL path params the serializer would otherwise generate + OpenApiParameter("feed_type", exclude=True), + OpenApiParameter("attack_type", exclude=True), + OpenApiParameter("prioritize", exclude=True), + OpenApiParameter("format", exclude=True), + ], + responses={ + 200: SimpleFeedEnvelopeSerializer, + 400: RESPONSES[400], + 429: RESPONSES[429], + }, ) - if paginate: - paginator = CustomPageNumberPagination() - iocs = paginator.paginate_queryset(iocs_queryset, request) - resp_data = feeds_response(request, iocs, feed_params, valid_feed_types, dict_only=True, verbose=verbose, include_sensors=True) - return paginator.get_paginated_response(resp_data) - return feeds_response(request, iocs_queryset, feed_params, valid_feed_types, verbose=verbose, include_sensors=True) - - -@api_view(["GET"]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -@throttle_classes([FeedsAdvancedThrottle]) -def feeds_asn(request): - """ - Retrieve aggregated IOC feed data grouped by ASN (Autonomous System Number). - - Args: - request: The HTTP request object. - feed_type (str): Filter by feed type (e.g. 'cowrie', 'honeytrap'). Default: 'all'. - attack_type (str): Filter by attack type (e.g., 'scanner', 'payload_request'). Default: 'all'. - max_age (int): Maximum age of IOCs in days. Default: 3. - min_days_seen (int): Minimum days an IOC must have been observed. Default: 1. - exclude_reputation (str): ';'-separated reputations to exclude (e.g., 'mass scanner'). Default: none. - ordering (str): Aggregation ordering field (e.g., 'total_attack_count', 'asn'). Default: '-ioc_count'. - asn (str, optional): Filter results to a single ASN. - - Returns: - Response: HTTP response with a JSON list of ASN aggregation objects. - Each object contains: - asn (int): ASN number. - ioc_count (int): Number of IOCs for this ASN. - total_attack_count (int): Sum of attack_count for all IOCs. - total_interaction_count (int): Sum of interaction_count for all IOCs. - total_login_attempts (int): Sum of login_attempts for all IOCs. - honeypots (List[str]): Sorted list of unique honeypots that observed these IOCs. - expected_ioc_count (float): Sum of recurrence_probability for all IOCs, rounded to 4 decimals. - expected_interactions (float): Sum of expected_interactions for all IOCs, rounded to 4 decimals. - first_seen (DateTime): Earliest first_seen timestamp among IOCs. - last_seen (DateTime): Latest last_seen timestamp among IOCs. - """ - logger.info(f"request /api/feeds/asn/ with params: {request.query_params}") - feed_params = FeedRequestParams(request.query_params) - valid_feed_types = get_valid_feed_types() - - iocs_qs = get_queryset(request, feed_params, valid_feed_types, is_aggregated=True, serializer_class=ASNFeedsOrderingSerializer) - - asn_aggregates = asn_aggregated_queryset(iocs_qs, request, feed_params) - data = list(asn_aggregates) - return Response(data) +) +class SimpleFeedView(BaseFeedView): + """Public feed endpoint with path parameters: + /feeds///.""" + + serializer_class = SimpleFeedRequestSerializer + + def get_request_data(self, request: Request, **kwargs) -> dict: + return request.query_params.dict() | { + "feed_type": kwargs["feed_type"], + "attack_type": kwargs["attack_type"], + "prioritize": kwargs["prioritize"], + "format": kwargs["format_"], + } -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def feeds_share(request): - """ - Generate a shareable link for the current feed configuration. - - Args: - request: The incoming request object. - feed_type (str): Type of feed to retrieve. - attack_type (str): Type of attack to filter. - max_age (int): Maximum number of days since last occurrence. - min_days_seen (int): Minimum number of days on which an IOC must have been seen. - include_reputation (str): `;`-separated list of reputation values to include. - exclude_reputation (str): `;`-separated list of reputation values to exclude. - ordering (str): Field to order results by. - verbose (bool): `true` to include IOC properties that contain a lot of data. - asn (int): Filter by ASN. - min_score (float): Filter by minimum recurrence_probability (0-1). - port (int): Filter by destination port. - start_date (str): Filter by start date (YYYY-MM-DD). - end_date (str): Filter by end date (YYYY-MM-DD). - reason (str): Optional human-readable label for this share token (max 256 chars). - - Returns: - Response: A JSON object containing the signed shareable URL. - """ - safe_params = {k: v for k, v in request.query_params.items() if k != "reason"} - logger.info(f"request /api/feeds/share with params: {safe_params}") - feed_params = FeedRequestParams(request.query_params) - data = vars(feed_params) - # Remove internal or non-serializable objects if any - data.pop("feed_type_sorting", None) - - reason = request.query_params.get("reason", "").strip()[:256] - - # Generate signed token and persist a ShareToken record - token = signing.dumps(data, salt="greedybear-feeds") - token_hash = hashlib.sha256(token.encode()).hexdigest() - ShareToken.objects.get_or_create( - token_hash=token_hash, - defaults={"user": request.user, "reason": reason}, +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Public paginated feed", + description="Public query-parameter feed, always paginated and always JSON.", + # Public endpoint: suppress the optional token scheme so it renders without a lock. + auth=[], + parameters=[SimpleFeedRequestSerializer, PAGE_PARAMETER], + responses={ + 200: PaginatedSimpleFeedSerializer, + 400: RESPONSES[400], + 429: RESPONSES[429], + }, ) +) +class PaginatedFeedView(BaseFeedView): + """Public paginated feed endpoint (query params only). Forces JSON output.""" + + serializer_class = SimpleFeedRequestSerializer + pagination_class = CustomPageNumberPagination + + def should_paginate(self, request_data: dict) -> bool: + """This endpoint always paginates.""" + return True + + def get_request_data(self, request: Request, **kwargs) -> dict: + """Pagination requires JSON response""" + return request.query_params.dict() | {"format": "json"} + + +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Authenticated advanced feed", + description=("Authenticated feed with the full set of filtering, scoring and credential parameters and optional pagination."), + parameters=[AdvancedFeedRequestSerializer, PAGE_PARAMETER], + responses={ + 200: AdvancedFeedEnvelopeSerializer, + 400: RESPONSES[400], + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) +) +class AdvancedFeedView(BaseFeedView): + """Authenticated advanced feed endpoint with full filtering and optional pagination.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [FeedsAdvancedThrottle] + serializer_class = AdvancedFeedRequestSerializer + pagination_class = CustomPageNumberPagination + include_sensors = True + + def get_queryset(self) -> QuerySet: + """Overrides base class to include credential count + and sensor information.""" + iocs = super().get_queryset() + + iocs = iocs.annotate(credential_count=Count("credentials", distinct=True)) + + if self.request_params["format"] in ["json", "ndjson"]: + iocs = iocs.annotate( + sensors_json=ArrayAgg( + JSONObject(address=F("sensors__address"), label=F("sensors__label")), + filter=Q(sensors__isnull=False), + default=Value([]), + distinct=True, + ) + ) + + return iocs + + +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Authenticated feed aggregated by ASN", + description=( + "Authenticated feed that aggregates the filtered IOCs into per-ASN metric rows. " + "Accepts the same filters as the advanced feed, but `ordering` is restricted to the aggregate fields." + ), + parameters=[ASNFeedRequestSerializer], + responses={ + 200: ASNFeedSerializer(many=True), + 400: RESPONSES[400], + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) +) +class AsnFeedView(BaseFeedView): + """Authenticated feed endpoint aggregated by ASN. - host = request.build_absolute_uri("/") - share_url = f"{host}api/feeds/consume/{token}" - revoke_url = f"{host}api/feeds/revoke/{token}" - return Response({"url": share_url, "revoke_url": revoke_url}) - - -@api_view([GET]) -@authentication_classes([]) -@permission_classes([]) -@throttle_classes([SharedFeedRateThrottle]) -def feeds_consume(request, token): + Reuses the shared base flow (validation, queryset building, statistics + recording) and only swaps the render step for the ASN aggregation. """ - Consume a shared feed using a signed token. - This endpoint is publicly accessible but strictly rate-limited. - Args: - request: The incoming request object. - token (str): The signed token containing feed configuration. + permission_classes = [IsAuthenticated] + throttle_classes = [FeedsAdvancedThrottle] + serializer_class = ASNFeedRequestSerializer + is_aggregated = True + + def render_response(self, request: Request, iocs_queryset: QuerySet) -> Response: + rows = aggregate_iocs_by_asn(iocs_queryset, self.request_params["ordering"]) + return Response(ASNFeedSerializer(rows, many=True).data) + + +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Public trending feed", + description=("Public endpoint that compares two consecutive completed windows of attacker activity and returns the top-ranked trending attackers."), + auth=[], + parameters=[TrendingFeedRequestSerializer], + responses={ + 200: TrendingFeedResponseSerializer, + 400: RESPONSES[400], + 429: RESPONSES[429], + }, + ) +) +class TrendingFeedView(RequestLoggingMixin, CachedResponseMixin, APIView): + authentication_classes = [] + permission_classes = [AllowAny] + throttle_classes = [FeedsThrottle] + cache_namespace = "trending_feeds" + cache_version_key = TRENDING_FEEDS_DATA_VERSION_KEY + + def get(self, request: Request) -> Response: + serializer = TrendingFeedRequestSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + validated = serializer.validated_data + cached_response = self.get_cached_response() + if cached_response is not None: + return cached_response + + current_window_end = timezone.now().replace(minute=0, second=0, microsecond=0) + current_window_start = current_window_end - timedelta(minutes=validated["window_minutes"]) + previous_window_end = current_window_start + previous_window_start = previous_window_end - timedelta(minutes=validated["window_minutes"]) + + bucket_repo = TrendingBucketRepository() + current_counts = bucket_repo.get_counts_in_window(current_window_start, current_window_end, validated["feed_type"]) + previous_counts = bucket_repo.get_counts_in_window(previous_window_start, previous_window_end, validated["feed_type"]) + attackers = build_ranked_attackers(current_counts, previous_counts, validated["limit"]) + response_payload = { + "window_minutes": validated["window_minutes"], + "feed_type": validated["feed_type"], + "current_window": { + "start": current_window_start, + "end": current_window_end, + }, + "previous_window": { + "start": previous_window_start, + "end": previous_window_end, + }, + "count": len(attackers), + "data_source": "aggregated", + "attackers": attackers, + } + return Response(TrendingFeedResponseSerializer(instance=response_payload).data) + + +@extend_schema_view( + get=extend_schema( + tags=["Feed Sharing"], + summary="Consume a shared feed token", + description=( + "Public, rate-limited endpoint that replays the advanced-feed request encoded in a signed " + "share token (`token` path param), so no query string is needed." + ), + responses={ + 200: AdvancedFeedEnvelopeSerializer, + 400: OpenApiResponse(description="Token is missing/revoked/badly signed, or its decoded feed parameters are invalid."), + 429: RESPONSES[429], + }, + ) +) +class ConsumeFeedView(AdvancedFeedView): + """Public, rate-limited endpoint that consumes a signed share token. - Returns: - Response: The HTTP response with formatted IOC data in JSON/CSV/TXT/STIX2.1. + Inherits AdvancedFeedView's queryset shaping (credential counts, sensors, + pagination) so a consumed token produces the same response as the advanced + feed. Only access control and the input source differ: it is public and + decodes the request from the token instead of the query string. """ - logger.info("request /api/feeds/consume with token") - token_hash = hashlib.sha256(token.encode()).hexdigest() - try: - share_token = ShareToken.objects.get(token_hash=token_hash) - except ShareToken.DoesNotExist: - return Response( - {"error": "Invalid or expired token"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if share_token.revoked: - return Response( - {"error": "Token has been revoked"}, - status=status.HTTP_400_BAD_REQUEST, - ) - try: - data = signing.loads(token, salt="greedybear-feeds", max_age=86400 * 30) # 30 days validity - except signing.BadSignature: - return Response({"error": "Invalid or expired token"}, status=status.HTTP_400_BAD_REQUEST) - - # Reconstruct params - feed_params = FeedRequestParams(data) - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) - return feeds_response(request, iocs_queryset, feed_params, valid_feed_types) + authentication_classes = [] + permission_classes = [AllowAny] + throttle_classes = [SharedFeedRateThrottle] + def get_request_data(self, request: Request, **kwargs) -> dict: + serializer = TokenConsumeRequestSerializer(data={"token": kwargs["token"]}) + serializer.is_valid(raise_exception=True) + return serializer.validated_data["feed_params"] -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def feeds_revoke(request, token): - """ - Revoke a previously generated shareable feed token. - - Once revoked, any attempt to consume the feed via that token will return a 400 error. - This is intentionally a GET endpoint so the revoke link can be opened directly in a browser. - Only the user who created the token (or staff) can revoke it. - Args: - request: The incoming request object. - token (str): The raw signed token to revoke. +@extend_schema(tags=["Feed Sharing"]) +class ShareTokenViewSet(RequestLoggingMixin, ViewSet): + """Create, list and revoke shareable feed tokens. - Returns: - Response: 200 on successful revocation, 400/403 if invalid, expired, or not authorized. + Share/revoke are intentionally GET-able so the links can be opened directly + in a browser. Share stores the raw query params in the signed token so + FeedsConsumeView can replay them through AdvancedFeedRequestSerializer. """ - logger.info("request /api/feeds/revoke") - try: - signing.loads(token, salt="greedybear-feeds", max_age=86400 * 30) - except signing.BadSignature: - return Response({"error": "Invalid or expired token"}, status=status.HTTP_400_BAD_REQUEST) - - token_hash = hashlib.sha256(token.encode()).hexdigest() - try: - share_token = ShareToken.objects.get(token_hash=token_hash) - except ShareToken.DoesNotExist: - return Response({"error": "Token not found. Only the creator can revoke a token."}, status=status.HTTP_403_FORBIDDEN) - - if share_token.user != request.user and not request.user.is_staff: - return Response({"error": "You do not have permission to revoke this token."}, status=status.HTTP_403_FORBIDDEN) - - if share_token.revoked: - return Response({"detail": "Token was already revoked."}, status=status.HTTP_200_OK) - share_token.revoked = True - share_token.revoked_at = timezone.now() - share_token.save(update_fields=["revoked", "revoked_at"]) - return Response({"detail": "Token revoked successfully."}, status=status.HTTP_200_OK) - - -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def feeds_tokens(request): - """ - List the calling user's share tokens with safe metadata. - - Returns only non-sensitive fields: a truncated hash prefix (first 12 hex - chars), the reason label, creation timestamp, and revocation status. - The raw token is never stored and therefore cannot be returned. - Returns: - Response: A JSON list of token metadata objects. - """ - logger.info("request /api/feeds/tokens/") - tokens = ShareToken.objects.filter(user=request.user).order_by("-created_at").values(*_TOKEN_LIST_FIELDS) - results = [ - { - "hash_prefix": t["token_hash"][:12], - "reason": t["reason"], - "created_at": t["created_at"], - "revoked": t["revoked"], - "revoked_at": t["revoked_at"], - } - for t in tokens - ] - return Response(results) + permission_classes = [IsAuthenticated] + throttle_classes = [FeedsAdvancedThrottle] + + @extend_schema( + summary="Create a shareable feed link", + description=( + "Encode the supplied advanced-feed query parameters into a signed share token and " + "return its public consume/revoke URLs. The optional `reason` is stored for auditing only." + ), + parameters=[ShareFeedRequestSerializer], + responses={ + 200: ShareTokenResponseSerializer, + 400: RESPONSES[400], + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) + def share(self, request: Request) -> Response: + request_serializer = ShareFeedRequestSerializer(data=request.query_params) + request_serializer.is_valid(raise_exception=True) + reason = request_serializer.validated_data["reason"] + + # The raw params (not the typed validated_data) are signed, + # so the token format and consume-time replay stay unchanged. + data = request.query_params.dict() + data.pop("reason", None) + token = signing.dumps(data, salt=SHARE_TOKEN_SALT) + token_hash = hashlib.sha256(token.encode()).hexdigest() + ShareToken.objects.get_or_create( + token_hash=token_hash, + defaults={"user": request.user, "reason": reason}, + ) + host = request.build_absolute_uri("/") + response_serializer = ShareTokenResponseSerializer( + { + "url": f"{host}api/feeds/consume/{token}", + "revoke_url": f"{host}api/feeds/revoke/{token}", + } + ) + return Response(response_serializer.data) + + @extend_schema( + summary="Revoke a shared feed token", + description="Revoke a previously shared token (`token` path param). Only the creator or a staff user may revoke it.", + responses={ + 200: OpenApiResponse(description="Confirmation that the token was revoked."), + 400: OpenApiResponse(description="Token is missing or has an invalid signature."), + 401: RESPONSES[401], + 403: OpenApiResponse(description="The caller is not the token's creator."), + 429: RESPONSES[429], + }, + ) + def revoke(self, request: Request, token: str) -> Response: + serializer = TokenRequestSerializer(data={"token": token}) + serializer.is_valid(raise_exception=True) + share_token = serializer.validated_data["share_token"] + if share_token.user != request.user and not request.user.is_staff: + return Response( + {"errors": {"non_field_errors": ["You do not have permission to revoke this token."]}}, + status=status.HTTP_403_FORBIDDEN, + ) + if share_token.revoked: + return Response({"detail": "Token was already revoked."}, status=status.HTTP_200_OK) + share_token.revoked = True + share_token.revoked_at = timezone.now() + share_token.save(update_fields=["revoked", "revoked_at"]) + return Response({"detail": "Token revoked successfully."}, status=status.HTTP_200_OK) + + @extend_schema( + summary="List the caller's share tokens", + description="Return the share tokens created by the authenticated user, most recent first.", + responses={ + 200: ShareTokenListItemSerializer(many=True), + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) + def list_tokens(self, request: Request) -> Response: + tokens = ShareToken.objects.filter(user=request.user).order_by("-created_at").values() + return Response(ShareTokenListItemSerializer(tokens, many=True).data) diff --git a/api/views/health.py b/api/views/health.py index 11ad256f0..5ee875dfc 100644 --- a/api/views/health.py +++ b/api/views/health.py @@ -2,14 +2,19 @@ import time from datetime import datetime, timedelta +from certego_saas.apps.auth.backend import CookieTokenAuthentication from django.conf import settings from django.db import connection from django.db.models import Count, Q from django_q.models import Schedule, Task -from rest_framework.decorators import api_view, permission_classes +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view +from rest_framework import status from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView +from api.serializers import HealthSerializer from greedybear.consts import START_TIME from greedybear.models import ( IOC, @@ -167,26 +172,23 @@ def get_status_overview(): } -@api_view(["GET"]) -@permission_classes([IsAdminUser]) -def health_view(request): - """ - Health & overview endpoint. - - Returns the current system status and aggregated observables.Accessible only to admin users.\ - - System status includes: - - database: "up", "down", or "degraded" - - qcluster: "up", "idle", or "down" - - elasticsearch: "up", "down", or "not configured" - - uptime_seconds: total system uptime in seconds - - Overview data includes: - - iocs: total and new IOCs in the last 24 hours - - sessions: total Cowrie sessions and sessions in the last 24h - - honeypots: total and active honeypots - - threat_lists: counts of firehol, mass_scanners, tor_exit_nodes - - jobs: Django-Q jobs (scheduled, failed last 24h, successful last 24h) - """ - data = get_status_overview() - return Response(data) +@extend_schema_view( + get=extend_schema( + tags=["Health"], + summary="Health & overview endpoint", + description=("Returns the current system status and aggregated observables. Accessible only to admin users."), + responses={ + 200: HealthSerializer, + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 403: OpenApiResponse(description="Permission denied — requires admin privileges."), + }, + ) +) +class HealthView(APIView): + authentication_classes = [CookieTokenAuthentication] + permission_classes = [IsAdminUser] + + def get(self, request: Request, *args, **kwargs): + data = get_status_overview() + response_serializer = HealthSerializer(data) + return Response(response_serializer.data, status=status.HTTP_200_OK) diff --git a/api/views/honeypots.py b/api/views/honeypots.py index ea2d51486..e5570f325 100644 --- a/api/views/honeypots.py +++ b/api/views/honeypots.py @@ -1,36 +1,39 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. -import logging - -from rest_framework.decorators import api_view +from certego_saas.apps.auth.backend import CookieTokenAuthentication +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView -from greedybear.consts import GET +from api.mixins import RequestLoggingMixin +from api.serializers import HoneypotRequestSerializer from greedybear.models import Honeypot -logger = logging.getLogger(__name__) - - -@api_view([GET]) -def general_honeypot_list(request): - """ - Retrieve a list of all general honeypots, optionally filtering by active status. - - Args: - request: The incoming request object containing query parameters. - - Returns: - Response: A JSON response containing the list of general honeypots. - """ - - logger.info(f"Requested honeypots list from {request.user}.") - active = request.query_params.get("onlyActive") - honeypots = [] - honeypot_objs = Honeypot.objects.all() - if active == "true": - honeypot_objs = honeypot_objs.filter(active=True) - logger.info(f"Requested only active honeypots from {request.user}") - honeypots.extend([hp.name for hp in honeypot_objs]) - logger.info(f"Honeypots: {honeypots} given back to user {request.user}") - return Response(honeypots) +class HoneypotView(RequestLoggingMixin, APIView): + authentication_classes = [CookieTokenAuthentication] + permission_classes = [AllowAny] + + @extend_schema( + tags=["Honeypots"], + summary="Retrieve a list of all honeypots", + description=("Retrieve a list of all honeypots, optionally filtering by active status."), + auth=[], + parameters=[HoneypotRequestSerializer], + responses={ + 200: OpenApiResponse( + response={"type": "array", "items": {"type": "string"}}, + description="A JSON response containing the names of the honeypots.", + ), + 400: OpenApiResponse(description="Invalid query parameter value."), + }, + ) + def get(self, request: Request, *args, **kwargs): + request_serializer = HoneypotRequestSerializer(data=request.query_params.dict()) + request_serializer.is_valid(raise_exception=True) + honeypots = Honeypot.objects.all() + if request_serializer.validated_data["only_active"]: + honeypots = honeypots.filter(active=True) + return Response(list(honeypots.values_list("name", flat=True))) diff --git a/api/views/payloads.py b/api/views/payloads.py new file mode 100644 index 000000000..7a3c8ae85 --- /dev/null +++ b/api/views/payloads.py @@ -0,0 +1,101 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +import logging + +from django.http import FileResponse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from api.permissions import IsThreatResearcherOrAdmin +from api.serializers.payloads import HoneypotPayloadSerializer +from greedybear.models import HoneypotPayload + +logger = logging.getLogger(__name__) + + +@extend_schema_view( + list=extend_schema( + summary="List payload metadata", + description="Returns paginated metadata (hashes, MIME type, source honeypot, size) for all captured honeypot payloads. Does not return raw files.", + tags=["Payloads"], + responses={ + 200: HoneypotPayloadSerializer(many=True), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + }, + ), + retrieve=extend_schema( + summary="Retrieve payload metadata", + description="Returns metadata for a single payload identified by its SHA256 hash.", + tags=["Payloads"], + responses={ + 200: HoneypotPayloadSerializer, + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 404: OpenApiResponse(description="No payload found with the given SHA256 hash."), + }, + ), +) +class HoneypotPayloadViewSet(viewsets.ReadOnlyModelViewSet): + """Read-only viewset for honeypot-captured payloads. + + ``list`` / ``retrieve`` — metadata only (hashes, MIME type, source + honeypot, size). Available to any authenticated user. + + ``download`` — streams the raw ``.vir`` quarantine file. Restricted + to staff or users in the ``threat_researcher`` group via + :class:`~api.permissions.IsThreatResearcherOrAdmin`. + """ + + queryset = HoneypotPayload.objects.prefetch_related("source_honeypots").order_by("-id") + serializer_class = HoneypotPayloadSerializer + permission_classes = [IsAuthenticated] + lookup_field = "sha256" + + @extend_schema( + summary="Download payload binary", + description="Streams the raw `.vir` quarantine file as an attachment. Restricted to staff or users in the `threat_researcher` group.", + tags=["Payloads"], + responses={ + 200: OpenApiResponse( + description="Raw binary file streamed as `application/octet-stream`.", + response=OpenApiTypes.BINARY, + ), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 403: OpenApiResponse(description="Permission denied — requires staff or `threat_researcher` group membership."), + 404: OpenApiResponse(description="Payload record not found, or the file has been removed from the quarantine directory."), + }, + ) + @action( + detail=True, + methods=["get"], + permission_classes=[IsAuthenticated, IsThreatResearcherOrAdmin], + url_path="download", + ) + def download(self, request, sha256=None): + payload = self.get_object() + + if not payload.payload_file: + return Response( + {"detail": "Payload file is not available for download."}, + status=status.HTTP_404_NOT_FOUND, + ) + + logger.info("user=%s downloaded payload sha256=%s", request.user.username, payload.sha256) + + try: + file_handle = payload.payload_file.open("rb") + except FileNotFoundError: + return Response( + {"detail": "Payload file is not available for download."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return FileResponse( + file_handle, + as_attachment=True, + filename=f"{payload.sha256}.vir", + content_type="application/octet-stream", + ) diff --git a/api/views/sensor.py b/api/views/sensor.py new file mode 100644 index 000000000..8907438a1 --- /dev/null +++ b/api/views/sensor.py @@ -0,0 +1,58 @@ +from certego_saas.apps.auth.backend import CookieTokenAuthentication +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.mixins import RequestLoggingMixin +from api.serializers import SensorCreateResponseSerializer, SensorCreateSerializer +from api.views.utils import create_or_get_sensor, resolve_active_api_source + + +class SensorCreateView(RequestLoggingMixin, APIView): + authentication_classes = [CookieTokenAuthentication] + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Event Injection"], + summary="Sensor creation", + description=( + "This endpoint allows authenticated users to create or fetch a sensor using an IP address as the unique identifier. " + "Each request is tied to the user's APISource, which is pre-created by an administrator. " + "If no APISource is linked to the user, the request is rejected." + ), + request=SensorCreateSerializer, + responses={ + 200: OpenApiResponse(response=SensorCreateResponseSerializer, description="An existing sensor is fetched."), + 201: OpenApiResponse(response=SensorCreateResponseSerializer, description="A new sensor is created."), + 400: OpenApiResponse(description="Invalid input data (e.g. malformed IP, invalid country code)."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 403: OpenApiResponse(description="Missing `APISource` or locked account state."), + }, + ) + def post(self, request: Request, *args, **kwargs): + api_source, error_response = resolve_active_api_source(request) + if error_response: + return error_response + + serializer = SensorCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + sensor, created = create_or_get_sensor( + api_source=api_source, + validated_data=serializer.validated_data.copy(), + ) + + response_serializer = SensorCreateResponseSerializer( + { + "id": sensor.id, + "message": ("Sensor created successfully" if created else "Sensor already existed"), + } + ) + + return Response( + response_serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) diff --git a/api/views/statistics.py b/api/views/statistics.py index bbcdb017a..23b0a1276 100644 --- a/api/views/statistics.py +++ b/api/views/statistics.py @@ -5,17 +5,18 @@ from certego_saas.ext.helpers import parse_humanized_range from django.db.models import Count, Q from django.db.models.functions import Trunc -from django.http import HttpResponseServerError +from django.http import HttpResponseBadRequest from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response +from api.mixins import CachedResponseMixin from greedybear.models import IOC, Honeypot, Statistics, ViewType logger = logging.getLogger(__name__) -class StatisticsViewSet(viewsets.ViewSet): +class StatisticsViewSet(CachedResponseMixin, viewsets.ViewSet): """ A viewset for viewing and editing statistics related to feeds and enrichment data. @@ -23,6 +24,15 @@ class StatisticsViewSet(viewsets.ViewSet): as well as statistics on enrichment data. """ + # Only cache statistics derived from IOC data that is refreshed by the extraction. + # These actions can use extraction-driven invalidation; user/request-dependent statistics + # should stay uncached here so they reflect the latest per-user state. + cache_namespace = "statistics_ioc" + cacheable_actions = frozenset({"countries", "feeds_types"}) + + def _cache_enabled(self) -> bool: + return getattr(self, "action", None) in self.cacheable_actions + @action(detail=True, methods=["GET"]) def feeds(self, request, pk=None): """ @@ -46,8 +56,8 @@ def feeds(self, request, pk=None): elif pk == "downloads": annotations = {"Downloads": Count("source", filter=Q(view=ViewType.FEEDS_VIEW.value))} else: - logger.error("this is impossible. check the code") - return HttpResponseServerError() + logger.error(f"Invalid pk provided for feeds: {pk}") + return HttpResponseBadRequest() return self.__aggregation_response_static_statistics(annotations) @action(detail=True, methods=["get"]) @@ -73,8 +83,8 @@ def enrichment(self, request, pk=None): elif pk == "requests": annotations = {"Requests": Count("source", filter=Q(view=ViewType.ENRICHMENT_VIEW.value))} else: - logger.error("this is impossible. check the code") - return HttpResponseServerError() + logger.error(f"Invalid pk provided for enrichment: {pk}") + return HttpResponseBadRequest() return self.__aggregation_response_static_statistics(annotations) @action(detail=False, methods=["get"]) @@ -88,6 +98,10 @@ def countries(self, request): Returns: Response: A JSON list of {country, code, count} objects ordered by count descending. """ + cached_response = self.get_cached_response() + if cached_response is not None: + return cached_response + delta, _ = self.__parse_range(self.request) qs = ( IOC.objects.filter(last_seen__gte=delta) @@ -118,6 +132,10 @@ def feeds_types(self, request): Returns: Response: A JSON response containing the feed type statistics. """ + cached_response = self.get_cached_response() + if cached_response is not None: + return cached_response + # Build annotations for each active general honeypot annotations = {} honeypots = Honeypot.objects.all().filter(active=True) diff --git a/api/views/utils.py b/api/views/utils.py index 29ad8c090..df0f40d53 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -1,27 +1,23 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. -import csv -import hashlib import logging -import urllib.parse -from datetime import datetime, timedelta +import uuid +from datetime import timedelta import feedparser -import requests from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.core.cache import cache, caches -from django.db.models import Count, F, Max, Min, Q, Sum, Value -from django.db.models.functions import JSONObject -from django.http import HttpResponse, HttpResponseBadRequest, StreamingHttpResponse +from django.core.cache import cache +from django.db import transaction +from django.db.models import Count, F, Max, Min, Sum from rest_framework import status +from rest_framework.request import Request from rest_framework.response import Response from stix2 import Bundle, ExternalReference, Indicator -from api.serializers import FeedsRequestSerializer, parse_feed_types -from greedybear.consts import CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL -from greedybear.enums import IpReputation -from greedybear.models import IOC, Honeypot, Statistics +from greedybear.consts import APISOURCE_LOCKED_THRESHOLD, CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL +from greedybear.cronjobs.http_client import HttpClient +from greedybear.models import APISource, AutonomousSystem, EventStatus, EventStatusType, RawEvent, Sensor, SourceType, Statistics from greedybear.utils import is_ip_address, is_valid_domain logger = logging.getLogger(__name__) @@ -31,24 +27,6 @@ class UnableToExtractSourceIPError(Exception): """Raised when no valid source IP can be extracted from the request.""" -class Echo: - """An object that implements just the write method of the file-like - interface. - This class is used to stream data in CSV format. - """ - - def write(self, value): - """Write the value by returning it, instead of storing in a buffer. - - Args: - value (str): The value to be written. - - Returns: - str: The same value that was passed. - """ - return value - - def get_request_source_ip(request) -> str: """Extract a normalized client IP from request metadata (X-Forwarded-For header) @@ -68,258 +46,13 @@ def get_request_source_ip(request) -> str: raise UnableToExtractSourceIPError("No valid source IP found in request metadata") -class FeedRequestParams: - """A class to handle and validate feed request parameters. - It processes and stores query parameters for feed requests, - providing default values. - - Attributes: - feed_type (str): comma-separated feed type string as supplied by the - caller (default: "all"). - feed_types (list[str]): List of individual feed type values derived from - ``feed_type`` by splitting on commas. - attack_type (str): Type of attack to filter (default: "all") - ioc_type (str): Type of IOC to filter - 'ip', 'domain', or 'all' (default: "all") - max_age (str): Maximum number of days since last occurrence (default: "3") - min_days_seen (str): Minimum number of days on which an IOC must have been seen (default: "1") - include_reputation (list): List of reputation values to include (default: []) - exclude_reputation (list): List of reputation values to exclude (default: []) - feed_size (int): Number of items to return in feed (default: "5000") - ordering (str): Field to order results by (default: "-last_seen") - verbose (str): Whether to include IOC properties that contain a lot of data (default: "false") - paginate (str): Whether to paginate results (default: "false") - format_ (str): Response format type (default: "json") - """ - - def __init__(self, query_params: dict): - """Initialize a new FeedRequestParams instance. - - Parameters: - query_params (dict): Dictionary containing query parameters for feed configuration. - """ - feed_type_str = query_params.get("feed_type", "all").lower() - self.feed_type = feed_type_str - self.feed_types = parse_feed_types(feed_type_str) - self.attack_type = query_params.get("attack_type", "all").lower() - self.ioc_type = query_params.get("ioc_type", "all").lower() - self.max_age = query_params.get("max_age", "3") - self.min_days_seen = query_params.get("min_days_seen", "1") - # Handle reputation lists (could be list from JSON or string from QueryDict) - inc_rep = query_params.get("include_reputation", []) - if isinstance(inc_rep, list): - self.include_reputation = inc_rep - else: - self.include_reputation = inc_rep.split(";") if inc_rep else [] - - exc_rep = query_params.get("exclude_reputation", []) - if isinstance(exc_rep, list): - self.exclude_reputation = exc_rep - else: - self.exclude_reputation = exc_rep.split(";") if exc_rep else [] - - self.feed_size = query_params.get("feed_size", "5000") - self.ordering = query_params.get("ordering", "-last_seen").lower().replace("value", "name") - self.verbose = query_params.get("verbose", "false").lower() - self.paginate = query_params.get("paginate", "false").lower() - # Support both format_ and format - self.format = query_params.get("format_", query_params.get("format", "json")).lower() - self.feed_type_sorting = None - self.asn = query_params.get("asn") - self.min_score = query_params.get("min_score") - self.min_expected_interactions = query_params.get("min_expected_interactions") - self.port = query_params.get("port") - self.start_date = query_params.get("start_date") - self.end_date = query_params.get("end_date") - self.country_code = query_params.get("country_code") - self.min_credential_count = query_params.get("min_credential_count") - self.max_credential_count = query_params.get("max_credential_count") - - def apply_default_filters(self, query_params): - if not query_params: - query_params = {} - if "include_mass_scanners" not in query_params: - self.exclude_reputation.append(IpReputation.MASS_SCANNER) - if "include_tor_exit_nodes" not in query_params: - self.exclude_reputation.append(IpReputation.TOR_EXIT_NODE) - - def set_prioritization(self, prioritize: str): - match prioritize: - case "recent": - self.max_age = "3" - self.min_days_seen = "1" - if "feed_type" in self.ordering: - self.feed_type_sorting = self.ordering - self.ordering = "-last_seen" - case "persistent": - self.max_age = "14" - self.min_days_seen = "10" - if "feed_type" in self.ordering: - self.feed_type_sorting = self.ordering - self.ordering = "-attack_count" - case "likely_to_recur": - self.max_age = "30" - self.min_days_seen = "1" - self.ordering = "-recurrence_probability" - case "most_expected_hits": - self.max_age = "30" - self.min_days_seen = "1" - self.ordering = "-expected_interactions" - - -def get_valid_feed_types() -> frozenset[str]: - """ - Retrieve all valid feed types, combining predefined types with active general honeypot names. - - Returns: - frozenset[str]: An immutable set of valid feed type strings - """ - honeypots = Honeypot.objects.filter(active=True) - feed_types = ["all"] + [hp.name.lower() for hp in honeypots] - return frozenset(feed_types) - - -def get_queryset( - request, - feed_params, - valid_feed_types, - is_aggregated=False, - serializer_class=FeedsRequestSerializer, - tag_key="", - tag_value="", - include_sensors=False, - include_credential_count=False, -): - """ - Build a queryset to filter IOC data based on the request parameters. - - Args: - request: The incoming request object. - feed_params: A FeedRequestParams instance. - valid_feed_types (frozenset): The set of all valid feed types. - is_aggregated (bool, optional): - - If True, disables slicing (`feed_size`) and model-level ordering. - - Ensures full dataset is available for aggregation or specialized computation. - - Default: False. - serializer_class (class, optional): - - Serializer class used to validate request parameters. - - Allows injecting a custom serializer to enforce rules for specific feed types - (e.g., to restrict ordering fields or validation for specialized feeds). - - Default: `FeedsRequestSerializer`. - tag_key (str, optional): Filter IOCs by tag key. Only passed from feeds_advanced. - tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring). Only passed from feeds_advanced. - include_sensors (bool, optional): If True, annotates sensors_json for each IOC. - Only passed from authenticated views like feeds_advanced. Default: False. - include_credential_count (bool, optional): If True, annotates credential Count for each IOC. - Only passed from authenticated views like feeds_advanced. Default: False. - Returns: - QuerySet: The filtered queryset of IOC data. - """ - source = str(request.user) - logger.info( - f"request from {source}. Feed type: {feed_params.feed_type}, attack_type: {feed_params.attack_type}, " - f"Age: {feed_params.max_age}, format: {feed_params.format}" - ) - - feed_params_data = {k: v for k, v in vars(feed_params).items() if v is not None} - serializer = serializer_class( - data=feed_params_data, - context={"valid_feed_types": valid_feed_types}, - ) - serializer.is_valid(raise_exception=True) - - query_dict = {} - if feed_params.attack_type != "all": - query_dict[feed_params.attack_type] = True - - if feed_params.ioc_type != "all": - query_dict["type"] = feed_params.ioc_type - - # Advanced filters - if feed_params.asn: - query_dict["autonomous_system__asn"] = feed_params.asn - if feed_params.min_score is not None: - query_dict["recurrence_probability__gte"] = feed_params.min_score - if feed_params.min_expected_interactions is not None: - query_dict["expected_interactions__gte"] = feed_params.min_expected_interactions - if feed_params.port: - query_dict["destination_ports__contains"] = [int(feed_params.port)] - if feed_params.country_code: - query_dict["attacker_country_code"] = feed_params.country_code.upper() - - # Date handling - if feed_params.start_date: - query_dict["last_seen__gte"] = feed_params.start_date - if feed_params.end_date: - query_dict["last_seen__lte"] = feed_params.end_date - - # Fallback to max_age ONLY if no date range is specified - if not (feed_params.start_date or feed_params.end_date): - query_dict["last_seen__gte"] = datetime.now() - timedelta(days=int(feed_params.max_age)) - - if int(feed_params.min_days_seen) > 1: - query_dict["number_of_days_seen__gte"] = int(feed_params.min_days_seen) - if feed_params.include_reputation: - query_dict["ip_reputation__in"] = feed_params.include_reputation - - if tag_key: - query_dict["tags__key"] = tag_key[:128] # Truncate to Tag.key max_length - if tag_value: - query_dict["tags__value__icontains"] = tag_value[:256] # Truncate to Tag.value max_length - - iocs = IOC.objects.filter(**query_dict).exclude(ip_reputation__in=feed_params.exclude_reputation).annotate(value=F("name")).distinct() - - # credential count filtering is only available on the advanced feed - if include_credential_count: - iocs = iocs.annotate(credential_count=Count("credentials", distinct=True)) - min_credential_count = serializer.validated_data.get("min_credential_count") - max_credential_count = serializer.validated_data.get("max_credential_count") - if min_credential_count is not None: - iocs = iocs.filter(credential_count__gte=min_credential_count) - if max_credential_count is not None: - iocs = iocs.filter(credential_count__lte=max_credential_count) - - # apply feed type filter as union; - if "all" not in feed_params.feed_types: - type_filter = Q() - for ft in feed_params.feed_types: - type_filter |= Q(honeypots__name__iexact=ft) - iocs = iocs.filter(type_filter) - - # aggregated feeds calculate metrics differently and need all rows to be accurate. - if not is_aggregated: - iocs = iocs.filter(honeypots__active=True) - iocs = iocs.annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True)) - # Only annotate tags metadata when the response format needs it (e.g. JSON), - # to avoid unnecessary joins and aggregation work for txt/csv feeds. - if getattr(feed_params, "format", "").lower() == "json": - iocs = iocs.annotate( - tags_json=ArrayAgg( - JSONObject(key=F("tags__key"), value=F("tags__value"), source=F("tags__source")), - filter=Q(tags__isnull=False), - default=Value([]), - distinct=True, - ) - ) - if include_sensors: - iocs = iocs.annotate( - sensors_json=ArrayAgg( - JSONObject(address=F("sensors__address"), label=F("sensors__label")), - filter=Q(sensors__isnull=False), - default=Value([]), - distinct=True, - ) - ) - iocs = iocs.order_by(feed_params.ordering) - iocs = iocs[: int(feed_params.feed_size)] - - # save request source for statistics +def save_request_source(request: Request, view: str): try: source_ip = get_request_source_ip(request) - request_source = Statistics(source=source_ip) + request_source = Statistics(source=source_ip, view=view) request_source.save() except UnableToExtractSourceIPError: logger.warning("Skipping statistics recording due to unable to extract source IP") - return iocs def ioc_as_dict(ioc, fields: set) -> dict: @@ -336,233 +69,217 @@ def ioc_as_dict(ioc, fields: set) -> dict: return {k: v for k, v in ioc.__dict__.items() if k in fields} -def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=None, dict_only=False, verbose=False, include_sensors=False): - """ - Format the IOC data into the requested format (e.g., JSON, CSV, TXT). +# JSON output fields. `honeypot_names` and `destination_ports` are fetched to derive +# `feed_type` / `destination_port_count` and then dropped from each row. +JSON_BASE_FIELDS = ( + "value", + "first_seen", + "last_seen", + "attack_count", + "credential_count", + "interaction_count", + "scanner", + "payload_request", + "ip_reputation", + "login_attempts", + "recurrence_probability", + "expected_interactions", + "honeypot_names", + "destination_ports", + "attacker_country", + "attacker_country_code", + "autonomous_system", + "tags", +) +JSON_VERBOSE_FIELDS = ( + "days_seen", + "firehol_categories", +) +STIX_FIELDS = { + "value", + "type", + "first_seen", + "last_seen", + "recurrence_probability", + "honeypot_names", + "ip_reputation", +} + + +def stream_ioc_objects(iocs, verbose=False, include_sensors=False): + """Yield shaped IOC dicts one at a time for memory-efficient NDJSON streaming. + + Contains the same shaping logic as build_ioc_json_list but yields each + IOC dict individually instead of collecting them into a list. This allows + StreamingHttpResponse to send each row to the client immediately without + loading the entire dataset into memory first. Args: - iocs (QuerySet): The filtered queryset of IOC data. - feed_params (FeedRequestParams): Request parameters including format. - valid_feed_types (frozenset): The set of all valid feed types. - dict_only (bool): Return IOC dictionary instead of Response object. - verbose (bool): Include verbose fields (days_seen, destination_ports, honeypots, firehol_categories). + iocs (QuerySet | list): Filtered IOCs to render. + verbose (bool): Include verbose fields (days_seen, destination_ports, firehol_categories). + include_sensors (bool): Emit a `sensors` array when the `sensors_json` annotation is present. - Returns: - Response: The HTTP response containing formatted IOC data. + Yields: + dict: A single JSON-serializable IOC dict. """ - logger.info(f"Format feeds in: {feed_params.format}") - match feed_params.format: - case "txt": - text_lines = [f"# {settings.FEEDS_LICENSE}"] if settings.FEEDS_LICENSE else [] - text_lines += [ioc[0] for ioc in iocs.values_list("name")] - return HttpResponse("\n".join(text_lines), content_type="text/plain") - case "csv": - rows = [[f"# {settings.FEEDS_LICENSE}"]] if settings.FEEDS_LICENSE else [] - rows += [list(ioc) for ioc in iocs.values_list("name")] - pseudo_buffer = Echo() - writer = csv.writer(pseudo_buffer, quoting=csv.QUOTE_NONE) - return StreamingHttpResponse( - (writer.writerow(row) for row in rows), - content_type="text/csv", - headers={"Content-Disposition": 'attachment; filename="feeds.csv"'}, - status=200, - ) - case "json": - json_list = [] - - # Base fields always returned - base_fields = ( - "value", - "first_seen", - "last_seen", - "attack_count", - "credential_count", - "interaction_count", - "scanner", - "payload_request", - "ip_reputation", - "login_attempts", - "recurrence_probability", - "expected_interactions", - "honeypot_names", # used to build feed_type; removed from response - "destination_ports", # used to calculate destination_port_count - "attacker_country", - "attacker_country_code", - "autonomous_system", - "tags", - ) - - verbose_only_fields = ( - "days_seen", - "firehol_categories", - ) - - required_fields = base_fields + verbose_only_fields if verbose else base_fields - - # `tags_json` is annotated in get_queryset (only for JSON format) to avoid conflicting - # with the `tags` reverse FK on IOC. When the queryset comes from a repository method - # that does not annotate `tags_json` (e.g. the ML scoring path), exclude the field. - # `sensors_json` follows the same pattern and is only annotated for authenticated views. - if isinstance(iocs, list): - has_tags_annotation = bool(iocs) and hasattr(iocs[0], "tags_json") - has_sensors_annotation = include_sensors and bool(iocs) and hasattr(iocs[0], "sensors_json") - has_credential_count = bool(iocs) and hasattr(iocs[0], "credential_count") - else: - has_tags_annotation = "tags_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations - has_sensors_annotation = include_sensors and "sensors_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations - has_credential_count = "credential_count" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations - required_fields = tuple(("tags_json" if f == "tags" else f) for f in required_fields if f != "tags" or has_tags_annotation) - required_fields = tuple(f for f in required_fields if f != "credential_count" or has_credential_count) - if has_sensors_annotation: - required_fields = (*required_fields, "sensors_json") - - iocs_iter: object - if isinstance(iocs, list): - iocs_iter = (ioc_as_dict(ioc, set(required_fields)) for ioc in iocs) - else: - iocs_iter = iocs.values(*required_fields).iterator(chunk_size=2000) - for ioc in iocs_iter: - ioc_feed_type = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] - - data_ = ioc | { - "first_seen": ioc["first_seen"].strftime("%Y-%m-%d"), - "last_seen": ioc["last_seen"].strftime("%Y-%m-%d"), - "feed_type": ioc_feed_type, - "destination_port_count": len(ioc.get("destination_ports", [])), - "asn": ioc.get("autonomous_system", ""), - "tags": ioc.pop("tags_json", []), - **({"sensors": ioc.pop("sensors_json", [])} if has_sensors_annotation else {}), - } - - if not verbose: - data_.pop("destination_ports", None) - data_.pop("autonomous_system", None) - data_.pop("honeypot_names", None) - data_.pop("id", None) + required_fields = JSON_BASE_FIELDS + JSON_VERBOSE_FIELDS if verbose else JSON_BASE_FIELDS + + # `tags_json` is annotated in get_queryset (only for JSON format) to avoid conflicting + # with the `tags` reverse FK on IOC. When the queryset comes from a repository method + # that does not annotate `tags_json` (e.g. the ML scoring path), exclude the field. + # `sensors_json` follows the same pattern and is only annotated for authenticated views. + if isinstance(iocs, list): + has_tags_annotation = bool(iocs) and hasattr(iocs[0], "tags_json") + has_sensors_annotation = include_sensors and bool(iocs) and hasattr(iocs[0], "sensors_json") + has_credential_count = bool(iocs) and hasattr(iocs[0], "credential_count") + else: + annotations = getattr(getattr(iocs, "query", None), "annotations", {}) + has_tags_annotation = "tags_json" in annotations + has_sensors_annotation = include_sensors and "sensors_json" in annotations + has_credential_count = "credential_count" in annotations + required_fields = tuple(("tags_json" if f == "tags" else f) for f in required_fields if f != "tags" or has_tags_annotation) + required_fields = tuple(f for f in required_fields if f != "credential_count" or has_credential_count) + if has_sensors_annotation: + required_fields = (*required_fields, "sensors_json") + + if isinstance(iocs, list): + iocs_iter = (ioc_as_dict(ioc, set(required_fields)) for ioc in iocs) + else: + iocs_iter = iocs.values(*required_fields).iterator(chunk_size=2000) + + count = 0 + for ioc in iocs_iter: + count += 1 + ioc_feed_type = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] + + data_ = ioc | { + "first_seen": ioc["first_seen"].strftime("%Y-%m-%d"), + "last_seen": ioc["last_seen"].strftime("%Y-%m-%d"), + "feed_type": ioc_feed_type, + "destination_port_count": len(ioc.get("destination_ports", [])), + "asn": ioc.get("autonomous_system", ""), + "tags": ioc.pop("tags_json", []), + **({"sensors": ioc.pop("sensors_json", [])} if has_sensors_annotation else {}), + } + + if not verbose: + data_.pop("destination_ports", None) + data_.pop("autonomous_system", None) + data_.pop("honeypot_names", None) + data_.pop("id", None) + yield data_ + logger.info(f"Number of feeds returned: {count}") + + +def build_ioc_json_list(iocs, verbose=False, include_sensors=False) -> list[dict]: + """Shape a queryset (or list) of IOCs into the JSON feed row dicts. + + Pure data logic shared by the JSON renderer and the ML scoring path; it + builds the per-row dicts but performs no HTTP/encoding work. Eagerly + collects `stream_ioc_objects`; use that generator directly when the rows + can be consumed lazily. - json_list.append(data_) + Args: + iocs (QuerySet | list): Filtered IOCs to render. + verbose (bool): Include verbose fields (days_seen, destination_ports, firehol_categories). + include_sensors (bool): Emit a `sensors` array when the `sensors_json` annotation is present. - if feed_params.feed_type_sorting is not None: - logger.info("Return feeds sorted by feed_type field") - json_list = sorted( - json_list, - key=lambda k: k["feed_type"], - reverse=feed_params.feed_type_sorting == "-feed_type", + Returns: A list of JSON-serializable IOC dicts. + """ + return list(stream_ioc_objects(iocs, verbose=verbose, include_sensors=include_sensors)) + + +def build_feed_dict(iocs, verbose=False, include_sensors=False) -> dict: + """Wrap the JSON feed rows in the public response envelope, attaching the license when set.""" + resp_data = {"iocs": build_ioc_json_list(iocs, verbose=verbose, include_sensors=include_sensors)} + if settings.FEEDS_LICENSE: + resp_data["license"] = settings.FEEDS_LICENSE + return resp_data + + +def build_stix_bundle(iocs, request=None) -> str: + """Serialize a queryset (or list) of IOCs into a STIX 2.1 bundle JSON string.""" + iocs = (ioc_as_dict(ioc, STIX_FIELDS) for ioc in iocs) if isinstance(iocs, list) else iocs.values(*STIX_FIELDS) + + stix_objects = [] + for ioc in iocs: + value = ioc["value"] + ioc_type = ioc["type"] + + # Validate and sanitize value before inserting into STIX pattern + # to prevent pattern injection via malicious IOC values. + if ioc_type == "ip": + if not is_ip_address(value): + logger.warning(f"Skipping IOC with invalid IP value for STIX export: {value!r}") + continue + stix_type = "ipv6-addr" if ":" in value else "ipv4-addr" + pattern = f"[{stix_type}:value = '{value}']" + else: # domain + if not is_valid_domain(value): + logger.warning(f"Skipping IOC with unsafe domain value for STIX export: {value!r}") + continue + pattern = f"[domain-name:value = '{value}']" + + # Confidence 0-100. + # We use a fixed high confidence (90) for honeypot observations as they are highly reliable. + confidence = 90 + + # Labels + labels = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] + if ioc.get("ip_reputation"): + labels.append(ioc["ip_reputation"]) + + indicator = Indicator( + name=value, + pattern=pattern, + pattern_type="stix", + valid_from=ioc["first_seen"], + valid_until=ioc["last_seen"] + timedelta(days=1), + labels=labels, + confidence=confidence, + description=f"Detected by GreedyBear honeypots: {', '.join(labels)}", + external_references=[ + ExternalReference( + source_name="GreedyBear", + url=(request.build_absolute_uri(f"/?query={value}") if request else f"https://greedybear.honeynet.org/?query={value}"), ) + ], + ) + stix_objects.append(indicator) - logger.info(f"Number of feeds returned: {len(json_list)}") - resp_data = {"iocs": json_list} - if settings.FEEDS_LICENSE: - resp_data["license"] = settings.FEEDS_LICENSE - if dict_only: - return resp_data - return Response(resp_data, status=status.HTTP_200_OK) - case "stix21": - stix_fields = { - "value", - "type", - "first_seen", - "last_seen", - "recurrence_probability", - "honeypot_names", - "ip_reputation", - } - # Fetch fields from database - iocs = (ioc_as_dict(ioc, stix_fields) for ioc in iocs) if isinstance(iocs, list) else iocs.values(*stix_fields) - - stix_objects = [] - for ioc in iocs: - value = ioc["value"] - ioc_type = ioc["type"] - - # Validate and sanitize value before inserting into STIX pattern - # to prevent pattern injection via malicious IOC values. - if ioc_type == "ip": - if not is_ip_address(value): - logger.warning(f"Skipping IOC with invalid IP value for STIX export: {value!r}") - continue - stix_type = "ipv6-addr" if ":" in value else "ipv4-addr" - pattern = f"[{stix_type}:value = '{value}']" - else: # domain - if not is_valid_domain(value): - logger.warning(f"Skipping IOC with unsafe domain value for STIX export: {value!r}") - continue - pattern = f"[domain-name:value = '{value}']" - - # Confidence 0-100. - # We use a fixed high confidence (90) for honeypot observations as they are highly reliable. - confidence = 90 - - # Labels - labels = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] - if ioc.get("ip_reputation"): - labels.append(ioc["ip_reputation"]) - - indicator = Indicator( - name=value, - pattern=pattern, - pattern_type="stix", - valid_from=ioc["first_seen"], - valid_until=ioc["last_seen"] + timedelta(days=1), - labels=labels, - confidence=confidence, - description=f"Detected by GreedyBear honeypots: {', '.join(labels)}", - external_references=[ - ExternalReference( - source_name="GreedyBear", - url=(request.build_absolute_uri(f"/?query={value}") if request else f"https://greedybear.honeynet.org/?query={value}"), - ) - ], - ) - stix_objects.append(indicator) + return Bundle(objects=stix_objects).serialize() - bundle = Bundle(objects=stix_objects) - return HttpResponse(bundle.serialize(), content_type="application/json") - case _: - return HttpResponseBadRequest() +def _asn_honeypot_lookup(with_asn) -> dict: + """Per-ASN active-honeypot names. -def asn_aggregated_queryset(iocs_qs, request, feed_params): - """ - Retrieve ASN aggregation data. Caches the heavy aggregation query - since the data only updates during the extraction cronjob. + Kept separate from the numeric aggregation because it filters on + honeypots.active, which changes independently of the IOC data. - Args - iocs_qs (QuerySet): Filtered IOC queryset from get_queryset; - request (Request): The API request object; - feed_params (FeedRequestParams): Validated parameter object + Args: + with_asn (QuerySet): IOC queryset already restricted to rows with an ASN. - Returns: A list of dicts with aggregated metrics and honeypot arrays per ASN. + Returns: A dict mapping ASN -> sorted-ready list of active honeypot names. """ + rows = with_asn.filter(honeypots__active=True).values(asn=F("autonomous_system__asn")).annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True)) + return {row["asn"]: row["honeypot_names"] or [] for row in rows} - # Build reliable cache key from query params - sorted_params = sorted(request.query_params.lists()) - params_string = urllib.parse.urlencode(sorted_params, doseq=True) - param_hash = hashlib.sha256(params_string.encode("utf-8")).hexdigest() - # To prevent per-worker continuous RAM bloat, use the shared DB-backed cache - # instead of the default LocMemCache, since the JSON response size can be large. - # The extraction pipeline invalidates this cache by bumping the version counter. - shared_cache = caches["django-q"] - version = shared_cache.get("asn_feeds_version", 1) - cache_key = f"asn_feeds_v{version}_{param_hash}" +def aggregate_iocs_by_asn(iocs_qs, ordering: str) -> list[dict]: + """Aggregate a filtered IOC queryset into per-ASN metric rows. - cached_result = shared_cache.get(cache_key) - if cached_result is not None: - return cached_result + Pure data logic: no request/cache concerns. IOCs without an ASN are dropped. - asn_filter = request.query_params.get("asn") - if asn_filter: - iocs_qs = iocs_qs.filter(autonomous_system__asn=asn_filter) + Args: + iocs_qs (QuerySet): Filtered IOC queryset from the view's get_queryset; + ordering (str): Validated aggregate ordering field (e.g. "-ioc_count"). - # default ordering is overridden here because of serializer default(-last-seen) behaviour - ordering = feed_params.ordering - if not ordering or ordering.strip() in {"", "-last_seen", "last_seen"}: - ordering = "-ioc_count" + Returns: A list of dicts with aggregated metrics and honeypot arrays per ASN. + """ + with_asn = iocs_qs.exclude(autonomous_system__isnull=True) numeric_agg = ( - iocs_qs.exclude(autonomous_system__isnull=True) - .values( + with_asn.values( asn=F("autonomous_system__asn"), as_name=F("autonomous_system__name"), ) @@ -576,36 +293,12 @@ def asn_aggregated_queryset(iocs_qs, request, feed_params): first_seen=Min("first_seen"), last_seen=Max("last_seen"), ) - ) - numeric_agg = numeric_agg.order_by(ordering) - - # Honeypot names still require a lightweight aggregation because - # they depend on the active flag which can change independently. - honeypot_agg = ( - iocs_qs.exclude(autonomous_system__isnull=True) - .filter(honeypots__active=True) - .values(asn=F("autonomous_system__asn")) - .annotate( - honeypot_names=ArrayAgg( - "honeypots__name", - distinct=True, - ) - ) + .order_by(ordering) ) - hp_lookup = {row["asn"]: row["honeypot_names"] or [] for row in honeypot_agg} + hp_lookup = _asn_honeypot_lookup(with_asn) - result = [] - for row in numeric_agg: - asn = row["asn"] - row_dict = dict(row) - row_dict["honeypots"] = sorted(hp_lookup.get(asn, [])) - result.append(row_dict) - - # Set cache with a 60-minute timeout (max extraction interval length) to prevent memory bloat - shared_cache.set(cache_key, result, timeout=3600) - - return result + return [{**row, "honeypots": sorted(hp_lookup.get(row["asn"], []))} for row in numeric_agg] def get_greedybear_news() -> list[dict]: @@ -621,8 +314,8 @@ def get_greedybear_news() -> list[dict]: return cached try: - response = requests.get(RSS_FEED_URL, timeout=5) - response.raise_for_status() + with HttpClient() as client: + response = client.get(RSS_FEED_URL, timeout=5) feed = feedparser.parse(response.content) filtered_entries = sorted( @@ -655,3 +348,168 @@ def get_greedybear_news() -> list[dict]: CACHE_TIMEOUT_SECONDS, ) return news_items + + +@transaction.atomic +def create_or_get_sensor(*, api_source, validated_data): + """ + Logic for sensor creation/retrieval. + """ + + asn_value = validated_data.pop("asn", None) + + autonomous_system = None + if asn_value: + autonomous_system, _ = AutonomousSystem.objects.get_or_create( + asn=asn_value, + defaults={"name": ""}, + ) + validated_data["autonomous_system"] = autonomous_system + + address = validated_data["address"] + + sensor, created = Sensor.objects.get_or_create( + address=address, + api_source=api_source, + defaults={ + **validated_data, + "source_type": SourceType.EXTERNAL, + }, + ) + + return sensor, created + + +def _bulk_create_raw_events(events_data: list[dict], batch: EventStatus, api_source: APISource) -> int: + """ + Validates sensor ownership and bulk-inserts raw event payloads into the database. + + This internal utility performs an optimized prefetch lookup of all referenced sensors to + ensure they exist and belong to the calling `APISource`. It validates the incoming list + upfront to fail fast on foreign or missing sensor identifiers, securely strips the mapping + fields to prevent schema mismatch unpacking errors, and stages the data into memory. + + Args: + events_data (list[dict]): A collection of un-persisted, validated event data dictionaries. + batch (EventStatus): The tracking batch model instance these events belong to. + api_source (APISource): The origin provider authority submitting the events. + + Returns: + int: The aggregate count of total RawEvent rows successfully inserted into the database. + + Raises: + ValueError: If any entry contains a sensor_id that does not exist or is not owned + by the given api_source. + + """ + chunk_size = 1000 + + # Prefetch all sensors in one query + sensor_ids = {e["sensor_id"] for e in events_data if "sensor_id" in e} + sensors_by_id = {s.id: s for s in Sensor.objects.filter(id__in=sensor_ids, api_source=api_source)} + + raw_events = [] + + for e in events_data: + sensor_id = e.get("sensor_id") + if sensor_id not in sensors_by_id: + raise ValueError(f"Invalid or missing sensor_id '{sensor_id}' for api_source {api_source.id}. ") + + for event in events_data: + sensor_id = event.get("sensor_id") + sensor = sensors_by_id.get(sensor_id) + + # creating a shallow copy (protects the original data from side effects) + event_fields = event.copy() + + # safely remove 'sensor_id' so it doesn't break ** unpacking + if "sensor_id" in event_fields: + del event_fields["sensor_id"] + + raw_events.append(RawEvent(sensor=sensor, batch=batch, **event_fields)) + + total = 0 + for i in range(0, len(raw_events), chunk_size): + chunk = raw_events[i : i + chunk_size] + RawEvent.objects.bulk_create(chunk) + total += len(chunk) + return total + + +def create_batch_and_events(events_data: list[dict], api_source: APISource) -> tuple[EventStatus, int]: + """ + Initializes a tracking batch and executes an atomic bulk-creation of RawEvents. + + Args: + events_data (list[dict]): A list of validated event data dictionaries to populate. + api_source (APISource): The authenticated origin provider entity submitting the batch. + + Returns: + tuple[EventStatus, int]: A tuple containing the created EventStatus tracking model + instance and the integer count of successfully stored events. + + Raises: + Exception: Re-raises any underlying database error encountered during the bulk creation + process after writing the crash state metadata. + """ + tracking_id = uuid.uuid4().hex + + batch = EventStatus.objects.create( + api_source=api_source, + task_id=tracking_id, + status=EventStatusType.PENDING, + ) + try: + with transaction.atomic(): + total_created = _bulk_create_raw_events( + events_data, + batch, + api_source, + ) + except Exception as e: + logger.exception(f"Database error during bulk-insert for batch {batch.task_id}") + + # updating the batch status here because it's outside the failed atomic block, + batch.status = EventStatusType.FAILED + batch.last_error = str(e) + batch.save(update_fields=["status", "last_error"]) + raise + + return batch, total_created + + +def increment_and_evaluate_lock(api_source: APISource) -> Response | None: + """ + Increments the failed batch attempts for an APISource and checks if the safety + threshold has been reached. If exceeded, it automatically locks the source. + + Returns a 403 Response if locked, otherwise returns None. + """ + api_source.invalid_event_count = F("invalid_event_count") + 1 + api_source.save(update_fields=["invalid_event_count"]) + + api_source.refresh_from_db() + + if api_source.invalid_event_count >= APISOURCE_LOCKED_THRESHOLD: + api_source.is_active = False + api_source.save(update_fields=["is_active"]) + return Response({"error": "Your APISource has been automatically locked due to excessive invalid batch submissions."}, status=status.HTTP_403_FORBIDDEN) + + return None + + +def resolve_active_api_source(request: Request) -> tuple[APISource | None, Response | None]: + """Resolve the caller's APISource or explain why it is unusable.""" + try: + api_source = request.user.api_source + except APISource.DoesNotExist: + return None, Response( + {"error": "No APISource linked to your account"}, + status=status.HTTP_403_FORBIDDEN, + ) + if not api_source.is_active: + return None, Response( + {"error": "APISource is locked"}, + status=status.HTTP_403_FORBIDDEN, + ) + return api_source, None diff --git a/authentication/throttles.py b/authentication/throttles.py new file mode 100644 index 000000000..6cd1f37c2 --- /dev/null +++ b/authentication/throttles.py @@ -0,0 +1,33 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class LoginIPThrottle(SimpleRateThrottle): + """Rate-limit login attempts from the same IP address.""" + + scope = "login" + + def get_cache_key(self, request, view): + return self.cache_format % { + "scope": self.scope, + "ident": self.get_ident(request), + } + + +class LoginIdentifierThrottle(SimpleRateThrottle): + """Rate-limit login attempts against the same username or email.""" + + scope = "login" + + def get_cache_key(self, request, view): + identifier = request.data.get("username") or request.data.get("email") + if not isinstance(identifier, str): + return None + + normalized = identifier.strip().lower() + if not normalized: + return None + + return self.cache_format % { + "scope": self.scope, + "ident": normalized, + } diff --git a/authentication/views.py b/authentication/views.py index fc02600f9..a66d96d61 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -30,6 +30,7 @@ LoginSerializer, RegistrationSerializer, ) +from .throttles import LoginIdentifierThrottle, LoginIPThrottle logger = logging.getLogger(__name__) @@ -124,6 +125,8 @@ def check_configuration(request): class LoginView(certego_views.LoginView): + throttle_classes = [LoginIPThrottle, LoginIdentifierThrottle] + @staticmethod def validate_and_return_user(request): serializer = LoginSerializer(data=request.data) diff --git a/configuration/nginx/http.conf b/configuration/nginx/http.conf index 2fbfcac14..85c14f323 100644 --- a/configuration/nginx/http.conf +++ b/configuration/nginx/http.conf @@ -3,9 +3,6 @@ upstream django_main { server unix:/run/gunicorn/main.sock fail_timeout=30s; } -uwsgi_cache_path /var/cache/nginx/feeds keys_zone=feeds_cache:10m max_size=10g - inactive=10m use_temp_path=off; - server { listen 80; server_name localhost; @@ -38,13 +35,8 @@ server { uwsgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; gzip on; - gzip_types application/json; + gzip_types application/json application/x-ndjson text/csv text/plain; gzip_min_length 1000; - - uwsgi_cache feeds_cache; - uwsgi_cache_key $scheme$host$uri$is_args$args; - uwsgi_cache_valid 200 10m; - add_header X-Cache-Status $upstream_cache_status; } location / { diff --git a/configuration/nginx/https.conf b/configuration/nginx/https.conf index df6afb00d..caa106a24 100644 --- a/configuration/nginx/https.conf +++ b/configuration/nginx/https.conf @@ -3,9 +3,6 @@ upstream django_main { server unix:/run/gunicorn/main.sock fail_timeout=30s; } -uwsgi_cache_path /var/cache/nginx/feeds keys_zone=feeds_cache:10m max_size=10g - inactive=10m use_temp_path=off; - server { listen 80; server_name greedybear.honeynet.com; @@ -54,13 +51,8 @@ server { uwsgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; gzip on; - gzip_types application/json; + gzip_types application/json application/x-ndjson text/csv text/plain; gzip_min_length 1000; - - uwsgi_cache feeds_cache; - uwsgi_cache_key $scheme$host$uri$is_args$args; - uwsgi_cache_valid 200 10m; - add_header X-Cache-Status $upstream_cache_status; } location / { diff --git a/docker/Dockerfile b/docker/Dockerfile index d2c02a12e..f1f5470a7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,6 +34,7 @@ ENV DJANGO_SETTINGS_MODULE=greedybear.settings ENV APP_ROOT=/opt/deploy/greedybear ENV LOG_PATH=/var/log/greedybear ENV UV_PROJECT_ENVIRONMENT=/usr/local +ENV QUARANTINE_PATH=/var/lib/greedybear/quarantine WORKDIR $APP_ROOT @@ -67,8 +68,8 @@ RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \ && touch ${LOG_PATH}/django/django_q.log ${LOG_PATH}/django/django_q_errors.log \ && touch ${LOG_PATH}/django/django_errors.log ${LOG_PATH}/django/elasticsearch.log \ && touch ${LOG_PATH}/django/authentication.log ${LOG_PATH}/django/authentication_errors.log \ - && mkdir -p ${APP_ROOT}/mlmodels \ - && chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${APP_ROOT}/mlmodels/ \ + && mkdir -p ${APP_ROOT}/mlmodels ${QUARANTINE_PATH} \ + && chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${APP_ROOT}/mlmodels/ ${QUARANTINE_PATH} \ && rm -rf frontend/ diff --git a/docker/Dockerfile_nginx b/docker/Dockerfile_nginx index b0c80e099..73bfacd8d 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -1,11 +1,11 @@ -FROM library/nginx:1.31.0-alpine +FROM library/nginx:1.31.4-alpine ENV NGINX_LOG_DIR=/var/log/nginx RUN apk add --no-cache bash # this is to avoid having logs redirected to stdout/stderr -RUN mkdir -p /var/cache/nginx /var/cache/nginx/feeds \ +RUN mkdir -p /var/cache/nginx \ && rm $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log \ && touch $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log diff --git a/docker/default.yml b/docker/default.yml index 699d4abfa..cab9e6a57 100644 --- a/docker/default.yml +++ b/docker/default.yml @@ -16,7 +16,7 @@ services: start_interval: 1s app: - image: intelowlproject/greedybear:prod + image: ghcr.io/greedybear-project/greedybear:prod container_name: greedybear_app restart: unless-stopped stop_grace_period: 30s @@ -25,6 +25,7 @@ services: - generic_logs:/var/log/greedybear - static_content:/opt/deploy/greedybear/static - gunicorn_sockets:/run/gunicorn + - quarantine_data:/var/lib/greedybear/quarantine entrypoint: - ./docker/entrypoint_gunicorn.sh command: ["gunicorn", "greedybear.wsgi:application", "-c", "/etc/gunicorn/config.py"] @@ -42,7 +43,7 @@ services: start_interval: 1s nginx: - image: intelowlproject/greedybear_nginx:prod + image: ghcr.io/greedybear-project/greedybear_nginx:prod container_name: greedybear_nginx restart: unless-stopped volumes: @@ -67,7 +68,7 @@ services: qcluster: - image: intelowlproject/greedybear:prod + image: ghcr.io/greedybear-project/greedybear:prod container_name: greedybear_qcluster restart: unless-stopped stop_grace_period: 3m @@ -77,6 +78,7 @@ services: volumes: - generic_logs:/var/log/greedybear - mlmodels:/opt/deploy/greedybear/mlmodels + - quarantine_data:/var/lib/greedybear/quarantine env_file: - env_file depends_on: @@ -94,3 +96,4 @@ volumes: static_content: mlmodels: gunicorn_sockets: + quarantine_data: diff --git a/docker/entrypoint_gunicorn.sh b/docker/entrypoint_gunicorn.sh index 3de924bdf..2ed5e8a01 100755 --- a/docker/entrypoint_gunicorn.sh +++ b/docker/entrypoint_gunicorn.sh @@ -1,12 +1,18 @@ #!/bin/bash +# checking if DJANGO_SECRET is set and not empty +if [ -z "$DJANGO_SECRET" ]; then + echo "ERROR: DJANGO_SECRET environment variable is not set!" >&2 + echo "Aborting startup." >&2 + exit 1 +fi + until cd /opt/deploy/greedybear do echo "Waiting for server volume..." done -# Apply database migrations -# Create cache table for Django Q monitoring (idempotent) +# Create DB cache tables for all DatabaseCache backends in settings.CACHES (idempotent) python manage.py createcachetable # Make durin migrations and migrate @@ -19,10 +25,11 @@ python manage.py collectstatic --noinput --clear --verbosity 0 # Ensure log directories exist (volumes may persist from older builds) mkdir -p /var/log/greedybear/gunicorn mkdir -p /run/gunicorn +mkdir -p /var/lib/greedybear/quarantine # Fix log file ownership (manage.py commands above run as root # and may create new log files owned by root instead of www-data) -chown -R www-data:www-data /var/log/greedybear /run/gunicorn +chown -R www-data:www-data /var/log/greedybear /run/gunicorn /var/lib/greedybear/quarantine # Obtain the current GreedyBear version number GREEDYBEAR_VERSION=$(uv version --short) diff --git a/docker/entrypoint_qcluster.sh b/docker/entrypoint_qcluster.sh index 965a94211..047b54506 100755 --- a/docker/entrypoint_qcluster.sh +++ b/docker/entrypoint_qcluster.sh @@ -1,7 +1,7 @@ #!/bin/bash -# Fix mlmodels ownership (volumes may retain files owned by a previous UID) -chown -R www-data:www-data /opt/deploy/greedybear/mlmodels +# Fix mlmodels and honeypot payloads ownership (volumes may retain files owned by a previous UID) +chown -R www-data:www-data /opt/deploy/greedybear/mlmodels /var/lib/greedybear/quarantine if [ "$DJANGO_TEST_SERVER" = "True" ]; then # Dev mode: run as root (needed for hot-reload on volume-mounted source) diff --git a/docker/env_file_template b/docker/env_file_template index a909c7792..2f2033fa9 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -76,6 +76,12 @@ COWRIE_SESSION_RETENTION = 365 # Days to keep unseen command sequences before deletion COMMAND_SEQUENCE_RETENTION = 365 +# Days to keep RawEvents data before deletion +RAW_EVENT_RETENTION = 7 + +# Days to keep EventStatus data before deletion +EVENT_STATUS_RETENTION = 30 + # ThreatFox API key. # Once added, your payload request domains will be submitted to ThreatFox. # Also used to download ThreatFox indicators for enrichment. @@ -85,11 +91,18 @@ THREATFOX_API_KEY = # Get your free API key from https://www.abuseipdb.com/ ABUSEIPDB_API_KEY = +# MalwareBazaar API key for submitting honeypot payloads to abuse.ch +# Get your free Auth-Key from https://auth.abuse.ch/ +MALWAREBAZAAR_API_KEY = + # Rate limiting for feeds endpoints (format: number/period, e.g. 30/minute) FEEDS_THROTTLE_RATE=30/minute FEEDS_ADVANCED_THROTTLE_RATE=100/minute FEEDS_SHARED_THROTTLE_RATE=10/minute +# Rate limiting for authentication endpoints (format: number/period, e.g. 30/minute) +LOGIN_THROTTLE_RATE=10/minute + # Trending attackers settings # Max API window in minutes (must be >= 60 and multiple of 60) TRENDING_MAX_WINDOW_MINUTES=22320 @@ -101,6 +114,19 @@ TRENDING_BUCKET_RETENTION_HOURS=744 # Example: https://github.com/honeynet/GreedyBear/blob/main/FEEDS_LICENSE.md FEEDS_LICENSE= +# T-Pot Payload Server URL for downloading honeypot payloads. +# If not set, payload extraction is disabled. +# Example: https://tpot.example.com:64299 +TPOT_PAYLOAD_SERVER_URL= + +# Optional API key for authenticating with the T-Pot Payload Server. +# When empty, authentication is disabled on both sides. +TPOT_PAYLOAD_SERVER_API_KEY= + +# Maximum disk usage (in GB) for the quarantine directory. +# Payload downloads are paused when this limit is reached. +MAX_QUARANTINE_SIZE_GB=5 + # Optional IntelOwl base URL. When set, a link to analyze each IOC on IntelOwl # will appear in the Feeds table. # Example: https://your-intelowl-instance.example.com diff --git a/docker/local.override.yml b/docker/local.override.yml index a006f8508..14f3ea642 100644 --- a/docker/local.override.yml +++ b/docker/local.override.yml @@ -4,7 +4,7 @@ services: context: .. dockerfile: docker/Dockerfile target: development - image: intelowlproject/greedybear:test + image: ghcr.io/greedybear-project/greedybear:test volumes: - ../:/opt/deploy/greedybear command: python manage.py runserver 0.0.0.0:8001 @@ -18,12 +18,12 @@ services: build: context: .. dockerfile: docker/Dockerfile_nginx - image: intelowlproject/greedybear_nginx:test + image: ghcr.io/greedybear-project/greedybear_nginx:test volumes: - ../configuration/nginx/django_server.conf:/etc/nginx/conf.d/default.conf qcluster: - image: intelowlproject/greedybear:test + image: ghcr.io/greedybear-project/greedybear:test volumes: - ../:/opt/deploy/greedybear command: sh -c "python manage.py setup_schedules && exec watchfiles --filter python 'python manage.py qcluster' /opt/deploy/greedybear/greedybear" diff --git a/docker/stag.override.yml b/docker/stag.override.yml index 74d8482bf..98eb5e75c 100644 --- a/docker/stag.override.yml +++ b/docker/stag.override.yml @@ -1,9 +1,9 @@ services: app: - image: intelowlproject/greedybear:stag + image: ghcr.io/greedybear-project/greedybear:stag nginx: - image: intelowlproject/greedybear_nginx:stag + image: ghcr.io/greedybear-project/greedybear_nginx:stag qcluster: - image: intelowlproject/greedybear:stag \ No newline at end of file + image: ghcr.io/greedybear-project/greedybear:stag \ No newline at end of file diff --git a/docker/version.override.yml b/docker/version.override.yml index d2b02e314..dcd11c6f2 100644 --- a/docker/version.override.yml +++ b/docker/version.override.yml @@ -1,16 +1,16 @@ # you have to populate the ENV variable REACT_APP_INTELOWL_VERSION in the .env file to have this work services: app: - image: intelowlproject/greedybear:${REACT_APP_INTELOWL_VERSION} + image: ghcr.io/greedybear-project/greedybear:${REACT_APP_INTELOWL_VERSION} nginx: - image: intelowlproject/greedybear_nginx:${REACT_APP_INTELOWL_VERSION} + image: ghcr.io/greedybear-project/greedybear_nginx:${REACT_APP_INTELOWL_VERSION} celery_beat: - image: intelowlproject/greedybear:${REACT_APP_INTELOWL_VERSION} + image: ghcr.io/greedybear-project/greedybear:${REACT_APP_INTELOWL_VERSION} celery_worker_default: - image: intelowlproject/greedybear:${REACT_APP_INTELOWL_VERSION} + image: ghcr.io/greedybear-project/greedybear:${REACT_APP_INTELOWL_VERSION} qcluster: - image: intelowlproject/greedybear:${REACT_APP_INTELOWL_VERSION} \ No newline at end of file + image: ghcr.io/greedybear-project/greedybear:${REACT_APP_INTELOWL_VERSION} \ No newline at end of file diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 000000000..4ba08b07e --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +allow-git=all diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4de0f96e..c0721b2df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,36 +8,37 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", - "@vnedyalk0v/react19-simple-maps": "^2.0.7", - "axios": "^1.16.1", + "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.1", + "@vnedyalk0v/react19-simple-maps": "^2.0.10", + "axios": "^1.20.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", "i18n-iso-countries": "^7.14.0", "prop-types": "^15.8.1", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-icons": "^5.6.0", - "react-router-dom": "^7.15.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-grid-layout": "^2.2.4", + "react-icons": "^5.7.0", + "react-router-dom": "^7.18.3", "react-use": "^17.6.1", "reactstrap": "^9.2.3", - "recharts": "^3.8.1", - "sass": "^1.99.0", - "zustand": "^5.0.13" + "recharts": "^3.10.1", + "sass": "^1.103.1", + "zustand": "^5.0.15" }, "devDependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.6", - "eslint": "^9.39.4", - "jsdom": "^29.1.1", - "prettier": "^3.8.3", - "stylelint": "^17.11.1", - "vite": "^8.0.16", - "vitest": "^4.1.6" + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^9.39.5", + "jsdom": "^30.0.1", + "prettier": "^3.9.6", + "stylelint": "^17.14.1", + "vite": "^8.2.2", + "vitest": "^4.1.11" } }, "node_modules/@adobe/css-tools": { @@ -48,56 +49,38 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -261,13 +244,13 @@ } }, "node_modules/@cacheable/memory": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", - "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/utils": "^2.4.0", + "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" @@ -301,9 +284,9 @@ } }, "node_modules/@cacheable/utils": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", - "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", "dev": true, "license": "MIT", "dependencies": { @@ -322,9 +305,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -342,9 +325,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -366,9 +349,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -382,8 +365,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -417,9 +400,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -531,40 +514,6 @@ "postcss-selector-parser": "^7.1.1" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -769,9 +718,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -781,7 +730,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -792,23 +741,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -843,9 +779,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -886,26 +822,26 @@ "license": "MIT" }, "node_modules/@greedybear/gb-ui": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/GreedyBear-Project/gb-ui.git#5ce8e6374c1f83797d736bbc177cd2621180a5aa", + "version": "1.0.1", + "resolved": "git+ssh://git@github.com/GreedyBear-Project/gb-ui.git#72f95eee3dc48e4353933ce747d2e6488bcc363c", "license": "MIT", "dependencies": { - "@microlink/react-json-view": "^1.31.18", + "@microlink/react-json-view": "^1.31.22", "@tanstack/react-table": "^8.21.3", - "classnames": "^2.3.1", - "date-fns": "^2.28.0", - "match-sorter": "^6.3.1", - "nanoid": "^3.3.4", - "react-compound-slider": "^3.3.1", - "react-icons": "^4.3.1", - "react-infinite-scroll-component": "^6.1.0", - "react-json-editor-ajrm": "^2.5.13", + "classnames": "^2.5.1", + "date-fns": "^2.30.0", + "match-sorter": "^6.4.0", + "nanoid": "^6.0.0", + "react-compound-slider": "^3.4.0", + "react-icons": "^5.7.0", + "react-infinite-scroll-component": "^7.2.1", + "react-json-editor-ajrm": "^2.5.14", "react-select": "^5.10.2", "react-share": "^5.3.0", "react-top-loading-bar": "^3.0.2", - "react-use": "^17.6.0", - "recharts": "^3.8.1", - "zustand": "^5.0.0" + "react-use": "^17.6.1", + "recharts": "^3.9.2", + "zustand": "^5.0.14" }, "engines": { "node": ">=20" @@ -921,13 +857,22 @@ "reactstrap": "^9.0.3" } }, - "node_modules/@greedybear/gb-ui/node_modules/react-icons": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", - "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "node_modules/@greedybear/gb-ui/node_modules/nanoid": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.0.tgz", + "integrity": "sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "peerDependencies": { - "react": "*" + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^22 || ^24 || >=26" } }, "node_modules/@humanfs/core": { @@ -1039,9 +984,9 @@ "license": "MIT" }, "node_modules/@microlink/react-json-view": { - "version": "1.31.20", - "resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.31.20.tgz", - "integrity": "sha512-gNLkGvjFDeAqVGvK3H7lfoDqetn/9lW2ugiYiJhchc7jQU1ZaKsZnt97ANluXWFfd/wifoA9TrVOTsUXwXCJwA==", + "version": "1.31.22", + "resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.31.22.tgz", + "integrity": "sha512-cfszidqClRqjnF5YQ5PSGeORo9MnMZvDv+csgq92G1LRZduWEBjeHXKLnulvhE7bX9+ORvjRCWcMQfwRlhPxmA==", "license": "MIT", "dependencies": { "react-base16-styling": "~0.10.0", @@ -1056,25 +1001,6 @@ "react-dom": ">= 15" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1114,9 +1040,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", "funding": { @@ -1455,20 +1381,27 @@ } } }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", - "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", "cpu": [ "arm64" ], @@ -1483,9 +1416,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", "cpu": [ "arm64" ], @@ -1500,9 +1433,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", "cpu": [ "x64" ], @@ -1517,9 +1450,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", "cpu": [ "x64" ], @@ -1534,9 +1467,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", "cpu": [ "arm" ], @@ -1551,9 +1484,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", "cpu": [ "arm64" ], @@ -1571,9 +1504,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", "cpu": [ "arm64" ], @@ -1591,9 +1524,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", "cpu": [ "ppc64" ], @@ -1611,9 +1544,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", "cpu": [ "s390x" ], @@ -1631,9 +1564,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", "cpu": [ "x64" ], @@ -1651,9 +1584,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", "cpu": [ "x64" ], @@ -1671,9 +1604,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", "cpu": [ "arm64" ], @@ -1687,29 +1620,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", "cpu": [ "arm64" ], @@ -1724,9 +1638,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", "cpu": [ "x64" ], @@ -1827,9 +1741,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1841,9 +1755,18 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -1854,9 +1777,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { @@ -1882,9 +1805,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", "dev": true, "license": "MIT", "engines": { @@ -1895,17 +1818,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2065,13 +1977,13 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -2079,6 +1991,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -2087,18 +2000,21 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", - "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2112,8 +2028,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.6", - "vitest": "4.1.6" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2122,16 +2038,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2140,13 +2056,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2167,9 +2083,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2180,13 +2096,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2194,14 +2110,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2210,9 +2126,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2220,13 +2136,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2235,9 +2151,9 @@ } }, "node_modules/@vnedyalk0v/react19-simple-maps": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@vnedyalk0v/react19-simple-maps/-/react19-simple-maps-2.0.7.tgz", - "integrity": "sha512-JB7u1pBwnR6SJGP+Vxq0b8BzgJW7AHRLNHLZEr9YlCoZtiC5hsCZab9ZvE9wzQcJ3TebD/j7F6WOomdkBjfHQg==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@vnedyalk0v/react19-simple-maps/-/react19-simple-maps-2.0.10.tgz", + "integrity": "sha512-tw8PYah/5c1UiwqhtB0k40p+jlYRW9QjdZZQ37CKONXErIZFHl0Sn8Ho03HNP/mXQs0YrogrBIeolln5mfbIsA==", "license": "MIT", "dependencies": { "d3-color": "^3.1.0", @@ -2263,9 +2179,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -2403,13 +2319,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2514,9 +2430,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2538,14 +2454,14 @@ } }, "node_modules/cacheable": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", - "integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/memory": "^2.0.8", - "@cacheable/utils": "^2.4.1", + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" @@ -2611,15 +2527,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -3298,9 +3214,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { @@ -3309,8 +3225,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3483,6 +3399,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3533,9 +3455,9 @@ "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -3684,16 +3606,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3852,10 +3774,23 @@ "which": "bin/which" } }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globby": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", - "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz", + "integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==", "dev": true, "license": "MIT", "dependencies": { @@ -3874,9 +3809,9 @@ } }, "node_modules/globby/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -3953,9 +3888,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4061,9 +3996,9 @@ } }, "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", "license": "MIT", "funding": { "type": "opencollective", @@ -4071,9 +4006,9 @@ } }, "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "license": "MIT" }, "node_modules/import-fresh": { @@ -4291,10 +4226,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4304,39 +4249,39 @@ } }, "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", + "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", + "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -4344,6 +4289,21 @@ } } }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4441,9 +4401,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -4457,23 +4417,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -4492,9 +4452,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -4513,9 +4473,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -4534,9 +4494,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -4555,9 +4515,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -4576,13 +4536,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4597,13 +4560,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4618,13 +4584,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4639,13 +4608,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4660,9 +4632,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -4681,9 +4653,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4762,9 +4734,9 @@ } }, "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4833,9 +4805,10 @@ } }, "node_modules/match-sorter": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz", - "integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.4.0.tgz", + "integrity": "sha512-d4664ahzdL1QTTvmK1iI0JsrxWeJ6gn33qkYtnPg3mcn+naBLtXSgSPOe+X2vUgtgGwaAk3eiaj7gwKjjMAq+Q==", + "deprecated": "This was arguably a breaking change. Not in API, but more results can be returned. Upgrade to the next major when you are ready for that", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.8", @@ -5030,9 +5003,10 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, "funding": [ { "type": "github", @@ -5233,9 +5207,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "devOptional": true, "license": "MIT", "engines": { @@ -5246,9 +5220,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -5266,7 +5240,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5302,9 +5276,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", "dev": true, "license": "MIT", "dependencies": { @@ -5333,9 +5307,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -5464,9 +5438,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5514,15 +5488,29 @@ "license": "ISC" }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.8" + } + }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" } }, "node_modules/react-fast-compare": { @@ -5531,25 +5519,44 @@ "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", "license": "MIT" }, + "node_modules/react-grid-layout": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz", + "integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.1.3", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-icons": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", - "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", "license": "MIT", "peerDependencies": { "react": "*" } }, "node_modules/react-infinite-scroll-component": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.1.tgz", - "integrity": "sha512-R8YoOyiNDynSWmfVme5LHslsKrP+/xcRUWR2ies8UgUab9dtyw5ECnMCVPPmnmjjF4MWQmfVdRwRWcWaDgeyMA==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-7.2.1.tgz", + "integrity": "sha512-yPuEf6VHBJXFt+YXDxjlnAuhorUwiA2cKURx/P6a7WW3k+TvwY/mbx7KP1rQ1hxGzDcwreczRyLIv6eSd1sFIw==", "license": "MIT", - "dependencies": { - "throttle-debounce": "^2.1.0" + "engines": { + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.0.0" + "react": ">=17", + "react-dom": ">=17" } }, "node_modules/react-is": { @@ -5601,10 +5608,24 @@ } } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", - "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -5624,12 +5645,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", - "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", "license": "MIT", "dependencies": { - "react-router": "7.15.1" + "react-router": "7.18.3" }, "engines": { "node": ">=20.0.0" @@ -5802,12 +5823,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -5815,9 +5836,9 @@ } }, "node_modules/recharts": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", - "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", "license": "MIT", "workspaces": [ "www" @@ -5828,9 +5849,9 @@ "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", - "immer": "^10.1.1", + "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", + "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" @@ -5896,9 +5917,9 @@ } }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/resize-observer-polyfill": { @@ -5928,13 +5949,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5944,21 +5965,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, "node_modules/rtl-css-js": { @@ -5995,12 +6016,12 @@ } }, "node_modules/sass": { - "version": "1.99.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", - "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "version": "1.103.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.103.1.tgz", + "integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==", "license": "MIT", "dependencies": { - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, @@ -6008,7 +6029,7 @@ "sass": "sass.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.19.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" @@ -6299,9 +6320,9 @@ } }, "node_modules/stylelint": { - "version": "17.11.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.11.1.tgz", - "integrity": "sha512-+smN/HqVTggUx3iuAzOi9fPh8SrH+cJWlZrYVldXoJ06orWBhZ4Ue/QEp64oei6pVrAh4w3tG+Y12Vw7MbCFRQ==", + "version": "17.14.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", + "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", "dev": true, "funding": [ { @@ -6315,23 +6336,23 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.2.0", + "@csstools/css-calc": "^3.2.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", - "cosmiconfig": "^9.0.1", + "cosmiconfig": "^9.0.2", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.2", + "file-entry-cache": "^11.1.5", "global-modules": "^2.0.0", - "globby": "^16.2.0", + "globby": "^16.2.1", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", @@ -6341,12 +6362,12 @@ "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.14", + "postcss": "^8.5.16", "postcss-safe-parser": "^7.0.1", - "postcss-selector-parser": "^7.1.1", + "postcss-selector-parser": "^7.1.4", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", - "supports-hyperlinks": "^4.4.0", + "supports-hyperlinks": "^4.5.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" @@ -6359,9 +6380,9 @@ } }, "node_modules/stylelint/node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { @@ -6386,23 +6407,23 @@ } }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz", - "integrity": "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.22" + "flat-cache": "^6.1.23" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.22", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz", - "integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "cacheable": "^2.3.4", + "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } @@ -6437,9 +6458,9 @@ } }, "node_modules/supports-hyperlinks": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", - "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz", + "integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==", "dev": true, "license": "MIT", "dependencies": { @@ -6580,15 +6601,6 @@ "node": ">=8" } }, - "node_modules/throttle-debounce": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", - "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -6646,22 +6658,22 @@ } }, "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.30" + "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", "dev": true, "license": "MIT" }, @@ -6699,9 +6711,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6750,13 +6762,13 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/unicorn-magic": { @@ -6866,16 +6878,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -6892,7 +6904,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -6944,19 +6956,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6984,12 +6996,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -7177,9 +7189,9 @@ } }, "node_modules/zustand": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", - "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index 86747e80f..7d21cf856 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,23 +9,24 @@ "prettier": "../.github/configurations/node_linters/prettier/.prettierrc.js" }, "dependencies": { - "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", - "@vnedyalk0v/react19-simple-maps": "^2.0.7", - "axios": "^1.16.1", + "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.1", + "@vnedyalk0v/react19-simple-maps": "^2.0.10", + "axios": "^1.20.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", "i18n-iso-countries": "^7.14.0", "prop-types": "^15.8.1", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-icons": "^5.6.0", - "react-router-dom": "^7.15.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-grid-layout": "^2.2.4", + "react-icons": "^5.7.0", + "react-router-dom": "^7.18.3", "react-use": "^17.6.1", "reactstrap": "^9.2.3", - "recharts": "^3.8.1", - "sass": "^1.99.0", - "zustand": "^5.0.13" + "recharts": "^3.10.1", + "sass": "^1.103.1", + "zustand": "^5.0.15" }, "scripts": { "start": "vite --port 3001", @@ -46,16 +47,16 @@ "d3-color": "^3.1.0" }, "devDependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.6", - "eslint": "^9.39.4", - "jsdom": "^29.1.1", - "prettier": "^3.8.3", - "stylelint": "^17.11.1", - "vite": "^8.0.16", - "vitest": "^4.1.6" + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^9.39.5", + "jsdom": "^30.0.1", + "prettier": "^3.9.6", + "stylelint": "^17.14.1", + "vite": "^8.2.2", + "vitest": "^4.1.11" } } diff --git a/frontend/src/components/Routes.jsx b/frontend/src/components/Routes.jsx index 150b64bcd..95fa3786e 100644 --- a/frontend/src/components/Routes.jsx +++ b/frontend/src/components/Routes.jsx @@ -3,6 +3,7 @@ import { FallBackLoading } from "@greedybear/gb-ui"; import IfAuthRedirectGuard from "../wrappers/ifAuthRedirectGuard"; import AuthGuard from "../wrappers/AuthGuard"; +import SuperuserGuard from "../wrappers/SuperuserGuard"; import ErrorBoundary from "../wrappers/ErrorBoundary"; const Home = React.lazy(() => import("./home/Home")); @@ -14,9 +15,11 @@ const ResetPassword = React.lazy(() => import("./auth/ResetPassword")); const Dashboard = React.lazy(() => import("./dashboard/Dashboard")); const Sessions = React.lazy(() => import("./me/sessions/Sessions")); const Feeds = React.lazy(() => import("./feeds/Feeds")); +const FeedsTrending = React.lazy(() => import("./feeds/FeedsTrending")); const ChangePassword = React.lazy( () => import("./me/changepassword/ChangePassword"), ); +const ConfigEditor = React.lazy(() => import("./dashboard/ConfigEditor")); // public components const publicRoutesLazy = [ @@ -38,6 +41,11 @@ const publicRoutesLazy = [ element: , withErrorBoundary: true, }, + { + path: "/feeds/trending", + element: , + withErrorBoundary: true, + }, ].map((r) => ({ ...r, element: ( @@ -106,6 +114,16 @@ const authRoutesLazy = [ element: , withErrorBoundary: true, }, + /* Dashboard Config */ + { + path: "/dashboard/config", + element: ( + + + + ), + withErrorBoundary: true, + }, ].map((r) => ({ ...r, element: ( diff --git a/frontend/src/components/dashboard/AttackOriginMap.jsx b/frontend/src/components/dashboard/AttackOriginMap.jsx index 19deb1824..3508f7605 100644 --- a/frontend/src/components/dashboard/AttackOriginMap.jsx +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -5,9 +5,11 @@ import { Geography, ZoomableGroup, } from "@vnedyalk0v/react19-simple-maps"; -import { useTimePickerStore } from "@greedybear/gb-ui"; import countries from "i18n-iso-countries"; -import useAttackerCountriesStore from "../../stores/useAttackerCountriesStore"; +import useWidgetData, { + normalizeAttackerCountries, +} from "../../hooks/useWidgetData"; +import { IOC_ATTACKER_COUNTRIES_URI } from "../../constants/api"; const WORLD_ATLAS_GEO_URL = `${import.meta.env.BASE_URL}countries-110m.json`; @@ -65,14 +67,16 @@ const MapPaths = React.memo( MapPaths.displayName = "MapPaths"; export default function AttackOriginMap() { - const { range } = useTimePickerStore(); const { - countryDataMap: countryData, - maxCount, + data: rawData, loading, error, - fetchData, - } = useAttackerCountriesStore(); + } = useWidgetData(IOC_ATTACKER_COUNTRIES_URI); + + const { countryDataMap, maxCount } = React.useMemo( + () => normalizeAttackerCountries(rawData), + [rawData], + ); // TopoJSON is loaded here (bypassing the library's broken URL validator) // and passed as an object to . @@ -87,10 +91,6 @@ export default function AttackOriginMap() { count: 0, }); - React.useEffect(() => { - fetchData(range); - }, [range, fetchData]); - React.useEffect(() => { let cancelled = false; fetch(WORLD_ATLAS_GEO_URL) @@ -114,7 +114,7 @@ export default function AttackOriginMap() { const colors = React.useMemo(() => { const colorMap = {}; if (maxCount <= 0) return colorMap; - for (const [alpha2, count] of Object.entries(countryData)) { + for (const [alpha2, count] of Object.entries(countryDataMap)) { if (!count) continue; const t = Math.sqrt(count / maxCount); colorMap[alpha2] = @@ -123,7 +123,7 @@ export default function AttackOriginMap() { : lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); } return colorMap; - }, [countryData, maxCount]); + }, [countryDataMap, maxCount]); /** * Resolve fill colour for a geography. @@ -142,7 +142,7 @@ export default function AttackOriginMap() { const handleMouseEnter = React.useCallback( (geo, evt) => { const alpha2 = countries.numericToAlpha2(geo.id); - const count = alpha2 ? (countryData[alpha2] ?? 0) : 0; + const count = alpha2 ? (countryDataMap[alpha2] ?? 0) : 0; setTooltip({ visible: true, x: evt.clientX, @@ -151,7 +151,7 @@ export default function AttackOriginMap() { count, }); }, - [countryData], + [countryDataMap], ); const handleMouseMove = React.useCallback((evt) => { @@ -235,7 +235,7 @@ export default function AttackOriginMap() { projection="geoNaturalEarth1" width={800} height={420} - style={{ width: "100%", height: "auto" }} + style={{ width: "100%", height: "auto", maxHeight: 440 }} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} > diff --git a/frontend/src/components/dashboard/ConfigEditor.jsx b/frontend/src/components/dashboard/ConfigEditor.jsx new file mode 100644 index 000000000..2e64bda78 --- /dev/null +++ b/frontend/src/components/dashboard/ConfigEditor.jsx @@ -0,0 +1,396 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { + Container, + Badge, + Dropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Row, + Col, +} from "reactstrap"; +import { Responsive, useContainerWidth } from "react-grid-layout"; +import { + MdArrowBack, + MdAdd, + MdClose, + MdSave, + MdRestartAlt, +} from "react-icons/md"; +import { useShallow } from "zustand/shallow"; + +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +import widgetRegistry from "./widgetRegistry"; +import WidgetWrapper from "./WidgetWrapper"; +import useDashboardStore from "../../stores/useDashboardStore"; + +function buildLayoutEntry(id, currentLgLayout, w = 12) { + const maxY = currentLgLayout.reduce( + (acc, item) => Math.max(acc, item.y + item.h), + 0, + ); + return { i: id, x: 0, y: maxY, w, h: 9, static: false }; +} + +// --------------------------------------------------------------------------- +// EditableWidgetCard +// Renders the real widget. A small floating X button sits in the top-right +// corner for removal. The whole card is the drag target. +// --------------------------------------------------------------------------- +function EditableWidgetCard({ cfg, definition, onRemove }) { + const { + component: WidgetComponent, + displayName, + defaultProps: registryDefaultProps, + } = definition; + const mergedProps = { ...(registryDefaultProps ?? {}), ...(cfg.props ?? {}) }; + + return ( +
+ + + + + {/* remove button */} + +
+ ); +} + +function AddWidgetDropdown({ availableWidgets, addedTypes, onAdd }) { + const [open, setOpen] = React.useState(false); + const unadded = availableWidgets.filter(([type]) => !addedTypes.has(type)); + + return ( + setOpen((o) => !o)}> + + + Add Widget + + + {unadded.map(([type, def]) => ( + { + onAdd(type); + setOpen(false); + }} + > + {def.displayName} + + ))} + + + ); +} + +export default function ConfigEditor() { + console.debug("ConfigEditor rendered!"); + + const { + layouts, + widgetConfigs, + isDirty, + setLayouts, + setWidgetConfigs, + saveToServer, + resetToServerDefault, + } = useDashboardStore( + useShallow((s) => ({ + layouts: s.layouts, + widgetConfigs: s.widgetConfigs, + isDirty: s.isDirty, + setLayouts: s.setLayouts, + setWidgetConfigs: s.setWidgetConfigs, + saveToServer: s.saveToServer, + resetToServerDefault: s.resetToServerDefault, + })), + ); + + const [isSaving, setIsSaving] = React.useState(false); + const [isResetting, setIsResetting] = React.useState(false); + + const { width, containerRef } = useContainerWidth(); + + const gridConfigs = React.useMemo( + () => widgetConfigs.filter((cfg) => !cfg.noGrid), + [widgetConfigs], + ); + + const noGridConfigs = React.useMemo( + () => widgetConfigs.filter((cfg) => cfg.noGrid), + [widgetConfigs], + ); + + const addedTypes = React.useMemo( + () => new Set(gridConfigs.map((cfg) => cfg.type)), + [gridConfigs], + ); + + // Widget types that are already rendered above the grid (noGrid: true). + const noGridTypes = React.useMemo( + () => + new Set(widgetConfigs.filter((cfg) => cfg.noGrid).map((cfg) => cfg.type)), + [widgetConfigs], + ); + + // All grid-capable widgets + const availableWidgets = React.useMemo( + () => + [...widgetRegistry.entries()].filter( + ([type, def]) => def.fillHeight !== undefined && !noGridTypes.has(type), + ), + [noGridTypes], + ); + + const handleAdd = React.useCallback( + (type) => { + const def = widgetRegistry.get(type); + if (!def) return; + const id = type; + const nextConfigs = [...widgetConfigs, { type, id, noGrid: false }]; + setWidgetConfigs(nextConfigs); + const lgEntry = buildLayoutEntry(id, layouts.lg ?? [], 12); + setLayouts({ + lg: [...(layouts.lg ?? []), lgEntry], + md: [...(layouts.md ?? []), { ...lgEntry, w: 12 }], + sm: [...(layouts.sm ?? []), { ...lgEntry, w: 12 }], + }); + }, + [widgetConfigs, layouts, setWidgetConfigs, setLayouts], + ); + + const handleRemove = React.useCallback( + (id) => { + setWidgetConfigs(widgetConfigs.filter((cfg) => cfg.id !== id)); + setLayouts({ + lg: (layouts.lg ?? []).filter((item) => item.i !== id), + md: (layouts.md ?? []).filter((item) => item.i !== id), + sm: (layouts.sm ?? []).filter((item) => item.i !== id), + }); + }, + [widgetConfigs, layouts, setWidgetConfigs, setLayouts], + ); + + const handleLayoutChange = React.useCallback( + (_currentLayout, allLayouts) => { + setLayouts({ + lg: allLayouts.lg ?? layouts.lg, + md: allLayouts.md ?? layouts.md, + sm: allLayouts.sm ?? layouts.sm, + }); + }, + [setLayouts, layouts], + ); + + const handleReset = React.useCallback(async () => { + if ( + window.confirm( + "Reset dashboard to defaults for all users? This will delete the saved server config.", + ) + ) { + setIsResetting(true); + try { + await resetToServerDefault(); + } finally { + setIsResetting(false); + } + } + }, [resetToServerDefault]); + + const handleSave = React.useCallback(async () => { + setIsSaving(true); + try { + await saveToServer(); + } finally { + setIsSaving(false); + } + }, [saveToServer]); + + const editableLayouts = React.useMemo(() => { + const makeEditable = (arr) => + (arr ?? []).map((item) => ({ ...item, static: false })); + return { + lg: makeEditable(layouts.lg), + md: makeEditable(layouts.md), + sm: makeEditable(layouts.sm), + }; + }, [layouts]); + + return ( + +
+ + + Dashboard + + +
Dashboard Config
+ + {isDirty && ( + + Unsaved changes + + )} + +
+ + + +
+
+ + {noGridConfigs.map((cfg, idx) => { + const definition = widgetRegistry.get(cfg.type); + if (!definition) return null; + const { + component: WidgetComponent, + displayName, + defaultHeight, + defaultProps: registryDefaultProps, + } = definition; + const mergedProps = { + ...(registryDefaultProps ?? {}), + ...(cfg.props ?? {}), + }; + return ( + 0 ? "mt-4 " : ""}mb-4`}> + + + + + + + ); + })} + +

+ Drag any widget to reorder · drag the bottom-right corner to resize +

+ +
+ {gridConfigs.length === 0 ? ( +
+ + No widgets — use Add Widget above to add one. + +
+ ) : ( + + {gridConfigs.map((cfg) => { + const definition = widgetRegistry.get(cfg.type); + if (!definition) return null; + return ( +
+ +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/Dashboard.jsx b/frontend/src/components/dashboard/Dashboard.jsx index 59cf0eceb..c94511cc6 100644 --- a/frontend/src/components/dashboard/Dashboard.jsx +++ b/frontend/src/components/dashboard/Dashboard.jsx @@ -1,151 +1,78 @@ import React from "react"; -import { Container, Row, Col } from "reactstrap"; +import { Link } from "react-router-dom"; +import { Container } from "reactstrap"; +import { MdSettings } from "react-icons/md"; +import { useShallow } from "zustand/shallow"; -import { - ElasticTimePicker, - useTimePickerStore, - SmallInfoCard, -} from "@greedybear/gb-ui"; +import { ElasticTimePicker, useTimePickerStore } from "@greedybear/gb-ui"; -import { - FeedsSourcesChart, - FeedsDownloadsChart, - EnrichmentSourcesChart, - EnrichmentRequestsChart, - FeedsTypesChart, - AttackOriginCountriesChart, -} from "./utils/charts"; +import DashboardRenderer from "./DashboardRenderer"; +import useDashboardStore from "../../stores/useDashboardStore"; +import { useAuthStore } from "../../stores"; -import EnrichmentLookup from "./EnrichmentLookup"; -import AttackOriginMap from "./AttackOriginMap"; +function Dashboard() { + console.debug("Dashboard rendered!"); + const { range, onTimeIntervalChange } = useTimePickerStore(); -const feedsChartList = [ - ["FeedsSourcesChart", "Feeds: Sources", FeedsSourcesChart], - ["FeedsDownloadsChart", "Feeds: Downloads", FeedsDownloadsChart], -]; + const isSuperuser = useAuthStore(React.useCallback((s) => s.isSuperuser, [])); -const feedsTypesChartList = [ - ["FeedsTypesChart", "Feeds: Types", FeedsTypesChart], -]; + const { widgetConfigs, layouts, savedVersion, loadFromServer } = + useDashboardStore( + useShallow((s) => ({ + widgetConfigs: s.widgetConfigs, + layouts: s.layouts, + savedVersion: s.savedVersion, + loadFromServer: s.loadFromServer, + })), + ); -const enrichmentChartList = [ - [ - "EnrichmentSourcesChart", - "Enrichment Service: Sources", - EnrichmentSourcesChart, - ], - [ - "EnrichmentRequestsChart", - "Enrichment Service: Requests", - EnrichmentRequestsChart, - ], -]; + // Fetch the globally persisted layout once per session. + // loadFromServer is guarded internally by `serverSynced` so it only fires + // when the user is authenticated and no fetch has been made yet this session. + React.useEffect(() => { + loadFromServer(); + }, [loadFromServer]); -function Dashboard() { - console.debug("Dashboard rendered!"); - const { range, onTimeIntervalChange } = useTimePickerStore(); + const staticLayouts = React.useMemo(() => { + const freeze = (arr) => + (arr ?? []).map((item) => ({ ...item, static: true })); + return { + lg: freeze(layouts.lg), + md: freeze(layouts.md), + sm: freeze(layouts.sm), + }; + }, [layouts]); return (

Dashboard

- -
- {/* Enrichment Lookup Section - Publicly visible */} - - - - - - } +
+ {isSuperuser && ( + + + Configure + + )} + - - +
+ - - {feedsTypesChartList.map(([id, header, Component]) => ( - - - - - } - style={{ minHeight: 360 }} - /> - - ))} - - - {feedsChartList.map(([id, header, Component]) => ( - - - - - } - style={{ minHeight: 360 }} - /> - - ))} - - - {enrichmentChartList.map(([id, header, Component]) => ( - - - - - } - style={{ minHeight: 360 }} - /> - - ))} - - - - - - - } - style={{ height: "100%" }} - /> - - - - - - } - style={{ height: "100%" }} - /> - - +
); } diff --git a/frontend/src/components/dashboard/DashboardRenderer.jsx b/frontend/src/components/dashboard/DashboardRenderer.jsx new file mode 100644 index 000000000..099d7be56 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardRenderer.jsx @@ -0,0 +1,152 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Row, Col } from "reactstrap"; +import { Responsive, useContainerWidth } from "react-grid-layout"; + +// react-grid-layout base styles +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +import widgetRegistry from "./widgetRegistry"; +import WidgetWrapper from "./WidgetWrapper"; + +/** + * @typedef {Object} WidgetConfig + * @property {string} type - Registry key (must exist in widgetRegistry.js) + * @property {string} id - Unique DOM id for this widget instance + * @property {boolean} [noGrid] - When true, widget is rendered as a Bootstrap + * Row ABOVE the RGL grid. Use for auto-height + * widgets like EnrichmentLookup that expand + * dynamically when results are displayed. + * @property {object} [props] - Extra props forwarded to the widget component; + * merged with the registry entry's defaultProps + */ + +/** + * DashboardRenderer + * 1. noGrid widgets: Bootstrap Rows stacked above the RGL grid. + * Cards auto-size to their content (no fixed height). + * 2. grid widgets: Inside a react-grid-layout `Responsive` grid driven + * by the `layouts` prop. + * + * @param {object} props + * @param {WidgetConfig[]} props.widgetConfigs + * @param {object} props.layouts + */ +function DashboardRenderer({ widgetConfigs, layouts = {} }) { + const { width, containerRef } = useContainerWidth(); + + const noGridConfigs = widgetConfigs.filter((cfg) => cfg.noGrid); + const gridConfigs = widgetConfigs.filter((cfg) => !cfg.noGrid); + + /** + * Look up the registry and return a fully-wrapped widget. + * Falls back to a visible warning if the registry key is unknown. + */ + const renderWidget = React.useCallback((cfg) => { + const definition = widgetRegistry.get(cfg.type); + + if (!definition) { + console.warn( + `[DashboardRenderer] Unknown widget type: "${cfg.type}". ` + + `Make sure it is registered in widgetRegistry.js.`, + ); + return ( +
+ Unknown widget: {cfg.type} +
+ ); + } + + const { + component: WidgetComponent, + displayName, + defaultHeight, + fillHeight, + defaultProps: registryDefaultProps, + } = definition; + + // Per-instance props win over registry defaults + const mergedProps = { + ...(registryDefaultProps ?? {}), + ...(cfg.props ?? {}), + }; + + return ( + + + + ); + }, []); + + return ( + <> + {noGridConfigs.map((cfg, idx) => ( + 0 ? "mt-4 " : ""}mb-4`}> + {renderWidget(cfg)} + + ))} + + {gridConfigs.length > 0 && ( +
+ + {gridConfigs.map((cfg) => ( +
+ {renderWidget(cfg)} +
+ ))} +
+
+ )} + + ); +} + +DashboardRenderer.propTypes = { + widgetConfigs: PropTypes.arrayOf( + PropTypes.shape({ + type: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + noGrid: PropTypes.bool, + props: PropTypes.object, + }), + ).isRequired, + layouts: PropTypes.objectOf( + PropTypes.arrayOf( + PropTypes.shape({ + i: PropTypes.string.isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + w: PropTypes.number.isRequired, + h: PropTypes.number.isRequired, + static: PropTypes.bool, + }), + ), + ), +}; + +export default DashboardRenderer; diff --git a/frontend/src/components/dashboard/WidgetWrapper.jsx b/frontend/src/components/dashboard/WidgetWrapper.jsx new file mode 100644 index 000000000..25535c2d0 --- /dev/null +++ b/frontend/src/components/dashboard/WidgetWrapper.jsx @@ -0,0 +1,113 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { SmallInfoCard } from "@greedybear/gb-ui"; + +class WidgetErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, info) { + console.error( + `[WidgetErrorBoundary] Widget "${this.props.widgetId}" threw:`, + error, + info, + ); + } + + render() { + if (this.state.hasError) { + return ( +
+ + ⚠️ + + Widget failed to render. +
+ ); + } + return this.props.children; + } +} + +WidgetErrorBoundary.propTypes = { + widgetId: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +// --------------------------------------------------------------------------- +// Props: +// id {string} DOM id forwarded to SmallInfoCard +// header {string} Card header text (widget displayName) +// minHeight {number|null} Optional min-height in px (noGrid widgets) +// fillHeight {boolean} When true, card takes full container height +// (used inside react-grid-layout slots) +// children {ReactNode} The widget component +// --------------------------------------------------------------------------- +function WidgetWrapper({ + id, + header, + minHeight = undefined, + fillHeight = false, + children, +}) { + const cardStyle = React.useMemo(() => { + if (fillHeight) return { height: "100%" }; + if (minHeight != null) return { minHeight }; + return undefined; + }, [fillHeight, minHeight]); + + return ( + {children}} + style={cardStyle} + /> + ); +} + +WidgetWrapper.propTypes = { + id: PropTypes.string.isRequired, + header: PropTypes.string.isRequired, + minHeight: PropTypes.number, + fillHeight: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +function SafeWidgetWrapper({ + id, + header, + minHeight = undefined, + fillHeight = false, + children, +}) { + return ( + + {children} + + ); +} + +SafeWidgetWrapper.propTypes = { + id: PropTypes.string.isRequired, + header: PropTypes.string.isRequired, + minHeight: PropTypes.number, + fillHeight: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +export default SafeWidgetWrapper; diff --git a/frontend/src/components/dashboard/defaultDashboardConfig.js b/frontend/src/components/dashboard/defaultDashboardConfig.js new file mode 100644 index 000000000..839e590e8 --- /dev/null +++ b/frontend/src/components/dashboard/defaultDashboardConfig.js @@ -0,0 +1,66 @@ +export const WIDGET_CONFIGS = [ + // EnrichmentLookup must auto-size because its card grows when results appear. + { + type: "EnrichmentLookup", + id: "enrichment-lookup", + noGrid: true, + }, + + // rendered inside react-grid-layout + { type: "FeedsTypesChart", id: "FeedsTypesChart" }, + { type: "FeedsSourcesChart", id: "FeedsSourcesChart" }, + { type: "FeedsDownloadsChart", id: "FeedsDownloadsChart" }, + { type: "EnrichmentSourcesChart", id: "EnrichmentSourcesChart" }, + { type: "EnrichmentRequestsChart", id: "EnrichmentRequestsChart" }, + { type: "AttackOriginMap", id: "AttackOriginMap" }, + { type: "AttackOriginCountriesChart", id: "AttackOriginCountriesChart" }, +]; + +// `static: true` prevents dragging/resizing +// set this to false for admin sessions and persist user layouts. + +export const DASHBOARD_LAYOUTS = { + lg: [ + { i: "FeedsTypesChart", x: 0, y: 0, w: 12, h: 9, static: true }, + { i: "FeedsSourcesChart", x: 0, y: 9, w: 6, h: 9, static: true }, + { i: "FeedsDownloadsChart", x: 6, y: 9, w: 6, h: 9, static: true }, + { i: "EnrichmentSourcesChart", x: 0, y: 18, w: 6, h: 9, static: true }, + { i: "EnrichmentRequestsChart", x: 6, y: 18, w: 6, h: 9, static: true }, + { i: "AttackOriginMap", x: 0, y: 27, w: 8, h: 12, static: true }, + { i: "AttackOriginCountriesChart", x: 8, y: 27, w: 4, h: 12, static: true }, + ], + + md: [ + { i: "FeedsTypesChart", x: 0, y: 0, w: 12, h: 9, static: true }, + { i: "FeedsSourcesChart", x: 0, y: 9, w: 12, h: 9, static: true }, + { i: "FeedsDownloadsChart", x: 0, y: 18, w: 12, h: 9, static: true }, + { i: "EnrichmentSourcesChart", x: 0, y: 27, w: 12, h: 9, static: true }, + { i: "EnrichmentRequestsChart", x: 0, y: 36, w: 12, h: 9, static: true }, + { i: "AttackOriginMap", x: 0, y: 45, w: 12, h: 12, static: true }, + { + i: "AttackOriginCountriesChart", + x: 0, + y: 55, + w: 12, + h: 12, + static: true, + }, + ], + + sm: [ + { i: "FeedsTypesChart", x: 0, y: 0, w: 12, h: 9, static: true }, + { i: "FeedsSourcesChart", x: 0, y: 9, w: 12, h: 9, static: true }, + { i: "FeedsDownloadsChart", x: 0, y: 18, w: 12, h: 9, static: true }, + { i: "EnrichmentSourcesChart", x: 0, y: 27, w: 12, h: 9, static: true }, + { i: "EnrichmentRequestsChart", x: 0, y: 36, w: 12, h: 9, static: true }, + { i: "AttackOriginMap", x: 0, y: 45, w: 12, h: 12, static: true }, + { + i: "AttackOriginCountriesChart", + x: 0, + y: 55, + w: 12, + h: 12, + static: true, + }, + ], +}; diff --git a/frontend/src/components/dashboard/utils/charts.jsx b/frontend/src/components/dashboard/utils/charts.jsx index 19f4f1fda..80e4a87d2 100644 --- a/frontend/src/components/dashboard/utils/charts.jsx +++ b/frontend/src/components/dashboard/utils/charts.jsx @@ -6,69 +6,130 @@ import { XAxis, YAxis, Tooltip, + CartesianGrid, + Legend, ResponsiveContainer, Cell, + ComposedChart, } from "recharts"; - -import { - AnyChartWidget, - getRandomColorsArray, - useTimePickerStore, -} from "@greedybear/gb-ui"; +import { format } from "date-fns"; +import { getRandomColorsArray, useTimePickerStore } from "@greedybear/gb-ui"; import { FEEDS_STATISTICS_SOURCES_URI, FEEDS_STATISTICS_DOWNLOADS_URI, FEEDS_STATISTICS_TYPES_URI, ENRICHMENT_STATISTICS_SOURCES_URI, ENRICHMENT_STATISTICS_REQUESTS_URI, + IOC_ATTACKER_COUNTRIES_URI, } from "../../../constants/api"; -import useAttackerCountriesStore from "../../../stores/useAttackerCountriesStore"; - import { FEED_COLOR_MAP, ENRICHMENT_COLOR_MAP } from "../../../constants"; +import useWidgetData, { + normalizeAttackerCountries, +} from "../../../hooks/useWidgetData"; const COUNTRY_BAR_COLOR = "#e05252"; +const CHART_HEIGHT = 250; +const CHART_MARGIN = { top: 0, right: 0, left: 20, bottom: 0 }; +const TOOLTIP_STYLE = { + backgroundColor: "var(--darker)", + border: 0, + borderRadius: 5, +}; // constants const colors = getRandomColorsArray(30, true); /** - * Creates an area chart component to avoid duplicating chart setup code. - * - * @param {string} name - Display name for the generated chart component. - * @param {string} url - API endpoint used to fetch chart data. - * @param {Object} colorMap - Map of data keys to color values. - * @param {number} start - Start index for slicing the color map. - * @param {number} end - End index for slicing the color map. + * Shared chart skeleton: handles loading / empty / error states and renders a + * ResponsiveContainer */ +function ChartSkeleton({ data, loading, error, children }) { + if (loading) { + return ( +
+ Loading… +
+ ); + } + if (error) { + return ( +
+ {error} +
+ ); + } + if (!data || data.length === 0) { + return ( +
No data in the selected range.
+ ); + } + return ( + + + + + + + + {children} + + + ); +} + +/** + * Transforms raw API response for a time-series chart: + * sorts by date ascending and formats date strings using the current dateFormat. + */ +function useChartData(rawData, dateFormat) { + return React.useMemo(() => { + if (!rawData || !Array.isArray(rawData) || rawData.length === 0) return []; + return [...rawData] + .sort((a, b) => new Date(a.date) - new Date(b.date)) + .map((o) => ({ ...o, date: format(new Date(o.date), dateFormat) })); + }, [rawData, dateFormat]); +} + +/** + * Creates an area chart component for a given API endpoint and colorMap slice. + */ +export const AreaChartWidget = React.memo(({ url, colorMap, start, end }) => { + const { dateFormat } = useTimePickerStore(); + const { data: rawData, loading, error } = useWidgetData(url); + const data = useChartData(rawData, dateFormat); + + const areas = React.useMemo( + () => + Object.entries(colorMap) + .slice(start, end) + .map(([key, color]) => ( + + )), + [colorMap, start, end], + ); + + return ( + + {areas} + + ); +}); +AreaChartWidget.displayName = "AreaChartWidget"; + export const createAreaChart = (name, url, colorMap, start, end) => { const Component = React.memo(() => { console.debug(`${name} rendered!`); - - const chartProps = React.useMemo( - () => ({ - url, - accessorFnAggregation: (d) => d, - componentsFn: () => - Object.entries(colorMap) - .slice(start, end) - .map(([key, color]) => ( - - )), - }), - [url, colorMap, start, end], + return ( + ); - - return ; }); - Component.displayName = name; - return Component; }; @@ -95,6 +156,7 @@ export const EnrichmentSourcesChart = createAreaChart( 0, 1, ); + export const EnrichmentRequestsChart = createAreaChart( "EnrichmentRequestsChart", ENRICHMENT_STATISTICS_REQUESTS_URI, @@ -106,48 +168,44 @@ export const EnrichmentRequestsChart = createAreaChart( export const FeedsTypesChart = React.memo(() => { console.debug("FeedsTypesChart rendered!"); - const chartProps = React.useMemo( - () => ({ - url: FEEDS_STATISTICS_TYPES_URI, - accessorFnAggregation: (d) => d, - componentsFn: (respData) => { - console.debug("respData", respData); - if (!respData || !respData?.length) return null; - - // Exctract keys only from respData[0]: - // feed types are the same for all elements of respData. - // Slice "date" field: we are only interested in feeds types. - const feedsTypes = []; - Object.entries(respData[0]) - .slice(1) - .map(([dKey], i) => (feedsTypes[i] = dKey)); - - // map each feed type to a color - return feedsTypes.map((dKey, i) => ( - - )); - }, - }), - [], - ); + const { dateFormat } = useTimePickerStore(); + const { + data: rawData, + loading, + error, + } = useWidgetData(FEEDS_STATISTICS_TYPES_URI); + const data = useChartData(rawData, dateFormat); + + const bars = React.useMemo(() => { + if (!data || data.length === 0) return null; + // Extract feed type keys from first data point (everything except "date") + const feedsTypes = Object.keys(data[0]).filter((k) => k !== "date"); + return feedsTypes.map((dKey, i) => ( + + )); + }, [data]); - return ; + return ( + + {bars} + + ); }); +FeedsTypesChart.displayName = "FeedsTypesChart"; export const AttackOriginCountriesChart = React.memo(() => { console.debug("AttackOriginCountriesChart rendered!"); - const { range } = useTimePickerStore(); const { - normalizedData: data, + data: rawData, loading, error, - fetchData, - } = useAttackerCountriesStore(); + } = useWidgetData(IOC_ATTACKER_COUNTRIES_URI); - React.useEffect(() => { - fetchData(range); - }, [range, fetchData]); + const { normalizedData: data } = React.useMemo( + () => normalizeAttackerCountries(rawData), + [rawData], + ); if (loading) { return ( @@ -168,7 +226,7 @@ export const AttackOriginCountriesChart = React.memo(() => { if (!data || data.length === 0) { return (
- No country data available for the selected time range. + No data in the selected range.
); } @@ -217,3 +275,4 @@ export const AttackOriginCountriesChart = React.memo(() => { ); }); +AttackOriginCountriesChart.displayName = "AttackOriginCountriesChart"; diff --git a/frontend/src/components/dashboard/widgetRegistry.js b/frontend/src/components/dashboard/widgetRegistry.js new file mode 100644 index 000000000..b46b56df8 --- /dev/null +++ b/frontend/src/components/dashboard/widgetRegistry.js @@ -0,0 +1,134 @@ +/** + * A Map registry for all dashboard widgets + * + * Each entry shape: + * { + * component: React.ComponentType - the widget component to render + * displayName: string - card header + * defaultHeight: number | null - min-height in px; only meaningful for + * noGrid widgets where the card auto-sizes. + * fillHeight: boolean - true = card fills its react-grid-layout slot (height 100%) + * endpoints: string[] - API endpoints this widget consumes + * defaultProps: object - default props forwarded to the component + * by DashboardRenderer + * } + */ + +import { + FeedsSourcesChart, + FeedsDownloadsChart, + EnrichmentSourcesChart, + EnrichmentRequestsChart, + FeedsTypesChart, + AttackOriginCountriesChart, +} from "./utils/charts"; + +import EnrichmentLookup from "./EnrichmentLookup"; +import AttackOriginMap from "./AttackOriginMap"; + +import { + FEEDS_STATISTICS_SOURCES_URI, + FEEDS_STATISTICS_DOWNLOADS_URI, + FEEDS_STATISTICS_TYPES_URI, + ENRICHMENT_STATISTICS_SOURCES_URI, + ENRICHMENT_STATISTICS_REQUESTS_URI, + IOC_ATTACKER_COUNTRIES_URI, + ENRICHMENT_URI, +} from "../../constants/api"; + +/** + * @typedef {Object} WidgetDefinition + * @property {React.ComponentType} component + * @property {string} displayName + * @property {number|null} [defaultHeight] + * @property {boolean} fillHeight + * @property {string[]} endpoints + * @property {object} defaultProps + */ + +/** @type {Map} */ +const widgetRegistry = new Map([ + [ + "EnrichmentLookup", + { + component: EnrichmentLookup, + displayName: "Enrichment Lookup", + defaultHeight: null, // auto-sizes via Bootstrap row + fillHeight: false, + endpoints: [ENRICHMENT_URI], + defaultProps: {}, + }, + ], + [ + "FeedsTypesChart", + { + component: FeedsTypesChart, + displayName: "Feeds: Types", + fillHeight: true, + endpoints: [FEEDS_STATISTICS_TYPES_URI], + defaultProps: {}, + }, + ], + [ + "FeedsSourcesChart", + { + component: FeedsSourcesChart, + displayName: "Feeds: Sources", + fillHeight: true, + endpoints: [FEEDS_STATISTICS_SOURCES_URI], + defaultProps: {}, + }, + ], + [ + "FeedsDownloadsChart", + { + component: FeedsDownloadsChart, + displayName: "Feeds: Downloads", + fillHeight: true, + endpoints: [FEEDS_STATISTICS_DOWNLOADS_URI], + defaultProps: {}, + }, + ], + [ + "EnrichmentSourcesChart", + { + component: EnrichmentSourcesChart, + displayName: "Enrichment Service: Sources", + fillHeight: true, + endpoints: [ENRICHMENT_STATISTICS_SOURCES_URI], + defaultProps: {}, + }, + ], + [ + "EnrichmentRequestsChart", + { + component: EnrichmentRequestsChart, + displayName: "Enrichment Service: Requests", + fillHeight: true, + endpoints: [ENRICHMENT_STATISTICS_REQUESTS_URI], + defaultProps: {}, + }, + ], + [ + "AttackOriginMap", + { + component: AttackOriginMap, + displayName: "Attack Origins: World Map", + fillHeight: true, + endpoints: [IOC_ATTACKER_COUNTRIES_URI], + defaultProps: {}, + }, + ], + [ + "AttackOriginCountriesChart", + { + component: AttackOriginCountriesChart, + displayName: "Attack Origins: Top Countries", + fillHeight: false, // chart height is data-driven (# countries × 28px) + endpoints: [IOC_ATTACKER_COUNTRIES_URI], + defaultProps: {}, + }, + ], +]); + +export default widgetRegistry; diff --git a/frontend/src/components/feeds/Feeds.jsx b/frontend/src/components/feeds/Feeds.jsx index f948b191a..f61f02f33 100644 --- a/frontend/src/components/feeds/Feeds.jsx +++ b/frontend/src/components/feeds/Feeds.jsx @@ -4,7 +4,7 @@ import { VscJson } from "react-icons/vsc"; import { TbLicense } from "react-icons/tb"; import { MdFilterAltOff } from "react-icons/md"; import { useLocation, useSearchParams } from "react-router-dom"; -import { FEEDS_BASE_URI, GENERAL_HONEYPOT_URI } from "../../constants/api"; +import { FEEDS_BASE_URI, HONEYPOT_URI } from "../../constants/api"; import { ContentSection, Select, @@ -97,9 +97,6 @@ function FeedsTable({ tableParams, onDataLoad, onSortChange }) { } export default function Feeds() { - console.debug("Feeds rendered!"); - console.debug("Feeds-DEFAULT_VALUES", DEFAULT_VALUES); - const [searchParams, setSearchParams] = useSearchParams(); const formikRef = React.useRef(null); @@ -146,10 +143,9 @@ export default function Feeds() { // API to extract general honeypot const [honeypots, Loader] = useAxiosComponentLoader({ - url: `${GENERAL_HONEYPOT_URI}?onlyActive=true`, + url: `${HONEYPOT_URI}?only_active=true`, headers: { "Content-Type": "application/json" }, }); - console.debug("Feeds-honeypots:", honeypots); const honeypotFeedsType = React.useMemo( () => @@ -190,21 +186,17 @@ export default function Feeds() { // callbacks const onSubmit = React.useCallback( (values) => { - try { - setFeedsState((prev) => ({ - url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, - tableParams: { - feed_type: values.feeds_type, - attack_type: values.attack_type, - ioc_type: values.ioc_type, - prioritize: values.prioritize, - }, - tableKey: prev.tableKey + 1, - })); - updateSearchParams(values); - } catch (e) { - console.debug(e); - } + setFeedsState((prev) => ({ + url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, + tableParams: { + feed_type: values.feeds_type, + attack_type: values.attack_type, + ioc_type: values.ioc_type, + prioritize: values.prioritize, + }, + tableKey: prev.tableKey + 1, + })); + updateSearchParams(values); }, [updateSearchParams], ); diff --git a/frontend/src/components/feeds/FeedsTrending.jsx b/frontend/src/components/feeds/FeedsTrending.jsx new file mode 100644 index 000000000..1face1a22 --- /dev/null +++ b/frontend/src/components/feeds/FeedsTrending.jsx @@ -0,0 +1,274 @@ +import React from "react"; +import { + Container, + Row, + Col, + FormGroup, + Label, + Button, + Badge, +} from "reactstrap"; + +import { + ContentSection, + Select, + useAxiosComponentLoader, +} from "@greedybear/gb-ui"; + +import { FEEDS_TRENDING_URI, HONEYPOT_URI } from "../../constants/api"; +import { MultiSelectDropdown } from "./MultiSelectDropdown"; + +const DEFAULT_PARAMS = Object.freeze({ + feed_type: "all", + window_minutes: "60", + limit: "10", +}); + +function TrendDeltaBadge({ delta }) { + if (!Number.isFinite(delta)) { + return -; + } + + const color = delta > 0 ? "danger" : delta < 0 ? "success" : "secondary"; + const prefix = delta > 0 ? "+" : ""; + return {`${prefix}${delta}`}; +} + +const windowChoices = [ + { label: "1 hour", value: "60" }, + { label: "2 hours", value: "120" }, + { label: "4 hours", value: "240" }, + { label: "8 hours", value: "480" }, + { label: "24 hours", value: "1440" }, +]; + +const limitChoices = [ + { label: "10 attackers", value: "10" }, + { label: "25 attackers", value: "25" }, + { label: "50 attackers", value: "50" }, + { label: "100 attackers", value: "100" }, +]; + +function formatWindowDate(value) { + if (!value) return ""; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + + return `${new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(date)} UTC`; +} + +function formatGrowthScore(value) { + const score = Number(value); + return Number.isFinite(score) ? score.toFixed(2) : "-"; +} + +export default function FeedsTrending() { + const [params, setParams] = React.useState(DEFAULT_PARAMS); + const [draft, setDraft] = React.useState(DEFAULT_PARAMS); + const draftRef = React.useRef(DEFAULT_PARAMS); + + const [honeypots, HoneypotLoader] = useAxiosComponentLoader({ + url: `${HONEYPOT_URI}?only_active=true`, + headers: { "Content-Type": "application/json" }, + }); + + const [payload, Loader, refetchTrending] = useAxiosComponentLoader({ + url: FEEDS_TRENDING_URI, + params, + headers: { "Content-Type": "application/json" }, + }); + + const onChange = React.useCallback((event) => { + const { name, value } = event.target; + setDraft((current) => { + const nextDraft = { ...current, [name]: value }; + draftRef.current = nextDraft; + return nextDraft; + }); + }, []); + + const onSubmit = React.useCallback( + (event) => { + event.preventDefault(); + const submittedDraft = draftRef.current; + + if (JSON.stringify(submittedDraft) === JSON.stringify(params)) { + refetchTrending(); + return; + } + + setParams({ ...submittedDraft }); + }, + [params, refetchTrending], + ); + + const honeypotFeedType = React.useMemo( + () => + honeypots.map((honeypot) => ({ + label: honeypot, + value: honeypot.toLowerCase(), + })), + [honeypots], + ); + + const selectedFeedTypes = React.useMemo( + () => + draft.feed_type && draft.feed_type !== "all" + ? draft.feed_type + .split(",") + .map((value) => + honeypotFeedType.find((option) => option.value === value), + ) + .filter(Boolean) + : [], + [draft.feed_type, honeypotFeedType], + ); + + return ( + +
+
+

Trending Feed

+ + Compare consecutive completed attack windows and highlight rising + attackers. + +
+
+ + ( +
+ + + + + { + const value = + selected.length > 0 + ? selected.map((option) => option.value).join(",") + : "all"; + + setDraft((current) => { + const nextDraft = { + ...current, + feed_type: value, + }; + draftRef.current = nextDraft; + return nextDraft; + }); + }} + /> + + + + + + + + + + + + + + + +
+ )} + /> +
+ ( + +
+
+ {payload.count} + attackers +
+ + {formatWindowDate(payload.current_window?.start)} to{" "} + {formatWindowDate(payload.current_window?.end)} + +
+ {payload.attackers?.length ? ( +
+ + + + + + + + + + + + + {payload.attackers.map((attacker) => ( + + + + + + + + + ))} + +
Attacker IPCurrentPreviousDeltaGrowth ScoreRank Delta
{attacker.attacker_ip}{attacker.current_interactions}{attacker.previous_interactions} + + {formatGrowthScore(attacker.growth_score)}{attacker.rank_delta ?? "-"}
+
+ ) : ( +
+ No trending attackers found for the selected window. +
+ )} +
+ )} + /> +
+ ); +} diff --git a/frontend/src/components/home/NewsWidget.jsx b/frontend/src/components/home/NewsWidget.jsx index 376948d72..0756006b5 100644 --- a/frontend/src/components/home/NewsWidget.jsx +++ b/frontend/src/components/home/NewsWidget.jsx @@ -2,21 +2,24 @@ import React from "react"; import { ContentSection } from "@greedybear/gb-ui"; import { Spinner } from "reactstrap"; import { GREEDYBEAR_NEWS_URL } from "../../constants/api"; +export const MAX_NEWS_ITEMS = 3; export const NewsWidget = React.memo(() => { const [data, setData] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(false); - const formatDate = (dateStr) => { if (!dateStr) return ""; const date = new Date(dateStr); - if (isNaN(date.getTime())) return dateStr; + if (isNaN(date.getTime())) return String(dateStr); - const day = date.getDate(); - const month = date.toLocaleDateString("en-US", { month: "short" }); - const year = date.getFullYear(); + const day = date.getUTCDate(); + const month = date.toLocaleDateString("en-US", { + month: "short", + timeZone: "UTC", + }); + const year = date.getUTCFullYear(); const ordinals = ["th", "st", "nd", "rd"]; const v = day % 100; @@ -33,7 +36,8 @@ export const NewsWidget = React.memo(() => { } return response.json(); }) - .then(setData) + + .then((newsData) => setData(newsData.slice(0, MAX_NEWS_ITEMS))) .catch((err) => { console.error("Error fetching news:", err); setError(true); diff --git a/frontend/src/constants/api.js b/frontend/src/constants/api.js index 710fd61c9..5ae5f16bb 100644 --- a/frontend/src/constants/api.js +++ b/frontend/src/constants/api.js @@ -22,12 +22,16 @@ export const SESSIONS_BASE_URI = `${AUTH_BASE_URI}/sessions`; export const APIACCESS_BASE_URI = `${AUTH_BASE_URI}/apiaccess`; //feeds export const FEEDS_BASE_URI = `${API_BASE_URI}/feeds`; +export const FEEDS_TRENDING_URI = `${FEEDS_BASE_URI}/trending/`; //enrichment export const ENRICHMENT_URI = `${API_BASE_URI}/enrichment`; //honeypot -export const GENERAL_HONEYPOT_URI = `${API_BASE_URI}/general_honeypot`; +export const HONEYPOT_URI = `${API_BASE_URI}/honeypot/`; // News export const GREEDYBEAR_NEWS_URL = `${API_BASE_URI}/news`; + +// Dashboard config +export const DASHBOARD_CONFIG_URI = `${API_BASE_URI}/dashboard-config/`; diff --git a/frontend/src/hooks/useWidgetData.js b/frontend/src/hooks/useWidgetData.js new file mode 100644 index 000000000..52a0d1352 --- /dev/null +++ b/frontend/src/hooks/useWidgetData.js @@ -0,0 +1,138 @@ +import React from "react"; +import axios from "axios"; +import { useTimePickerStore } from "@greedybear/gb-ui"; + +/** + * Map + * Entries expire after CACHE_TTL_MS. + */ +const cache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +const inflight = new Map(); + +export function clearWidgetDataCache() { + cache.clear(); + inflight.clear(); +} + +/** + * Normalise raw attacker-countries API data into two structures consumed by + * the dashboard map and bar-chart widgets. + * + * @param {any} rawData - Raw response from the API (may be null / non-array). + * @returns {{ + * countryDataMap: Record, // alpha-2 → aggregated count + * maxCount: number, // highest single-country count + * normalizedData: Array<{country:string, count:number, code:string}> // sorted desc + * }} + */ +export function normalizeAttackerCountries(rawData) { + const countryDataMap = {}; + const nameMap = {}; + let maxCount = 0; + + const raw = Array.isArray(rawData) ? rawData : []; + raw.forEach((item) => { + if (!item || typeof item !== "object") return; + const code = typeof item.code === "string" ? item.code.toUpperCase() : null; + if (!code) return; + const count = Math.max(0, Number(item.count) || 0); + countryDataMap[code] = (countryDataMap[code] || 0) + count; + if (!nameMap[code]) nameMap[code] = item.country || code; + if (countryDataMap[code] > maxCount) maxCount = countryDataMap[code]; + }); + + const normalizedData = Object.entries(countryDataMap) + .map(([code, count]) => ({ country: nameMap[code], count, code })) + .sort((a, b) => b.count - a.count); + + return { countryDataMap, maxCount, normalizedData }; +} + +/** + * Shared data-fetching hook for dashboard widgets. + * + * Reads `range` from `useTimePickerStore` automatically, so callers + * do not need to thread it through props. + * + * Caching: if (url, params, range) matches a recent cache entry (< 5 min old), + * the cached data is returned immediately without firing a network request. + * In-flight deduplication: if the same key is already being fetched by another + * component, the new caller subscribes to the same Promise instead of issuing + * a second GET. + * + * @param {string} url API endpoint to fetch. + * @param {Object} [extraParams={}] Additional query params merged with { range }. + * @returns {{ data: any, loading: boolean, error: string|null }} + */ +export default function useWidgetData(url, extraParams = {}) { + const { range } = useTimePickerStore(); + + const cacheKey = `${url}|${JSON.stringify(extraParams)}|${JSON.stringify(range)}`; + + const [data, setData] = React.useState(() => { + const entry = cache.get(cacheKey); + if (entry && Date.now() - entry.ts < CACHE_TTL_MS) return entry.data; + return null; + }); + const [loading, setLoading] = React.useState(() => { + const entry = cache.get(cacheKey); + return !(entry && Date.now() - entry.ts < CACHE_TTL_MS); + }); + const [error, setError] = React.useState(null); + + React.useLayoutEffect(() => { + const entry = cache.get(cacheKey); + if (entry && Date.now() - entry.ts < CACHE_TTL_MS) { + // Cache hit + setData(null); + setData(entry.data); + setLoading(false); + setError(null); + return; + } + + const controller = new AbortController(); + let cancelled = false; + + setLoading(true); + setError(null); + + // Reuse an in-flight promise if one already exists for this key, + // otherwise start a new request and register it. + let promise = inflight.get(cacheKey); + if (!promise) { + promise = axios + .get(url, { + params: { range, ...extraParams }, + signal: controller.signal, + }) + .then((resp) => resp.data ?? null) + .finally(() => inflight.delete(cacheKey)); + inflight.set(cacheKey, promise); + } + + promise + .then((result) => { + if (cancelled) return; + cache.set(cacheKey, { data: result, ts: Date.now() }); + setData(result); + setLoading(false); + }) + .catch((err) => { + if (cancelled || axios.isCancel(err)) return; + console.error(`[useWidgetData] fetch failed for ${url}:`, err); + setError("Failed to load data."); + setLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cacheKey]); + + return { data, loading, error }; +} diff --git a/frontend/src/layouts/AppHeader.jsx b/frontend/src/layouts/AppHeader.jsx index c3f773fab..1bfded80e 100644 --- a/frontend/src/layouts/AppHeader.jsx +++ b/frontend/src/layouts/AppHeader.jsx @@ -8,7 +8,12 @@ import { NavbarToggler, } from "reactstrap"; import { NavLink as RRNavLink } from "react-router-dom"; -import { MdHome, MdOutlineFeed, MdDashboard } from "react-icons/md"; +import { + MdHome, + MdOutlineFeed, + MdDashboard, + MdTrendingUp, +} from "react-icons/md"; import { RiBookReadFill } from "react-icons/ri"; // lib @@ -58,8 +63,6 @@ const rightLinks = ( ); function AppHeader() { - console.debug("AppHeader rendered!"); - // local state const [isOpen, setIsOpen] = React.useState(false); @@ -97,6 +100,12 @@ function AppHeader() { Feeds + + + + Trending + + {/* Navbar Right Side */}