From 79f9399666c9e2c5e9a01ea5f96e2af0374e5ff1 Mon Sep 17 00:00:00 2001 From: Arnav Vinod Deshpande Date: Thu, 16 Apr 2026 21:11:08 +0530 Subject: [PATCH 01/22] Adding Trending attackers feed. Progresses #1071 (#1103) * implemented * new * copilot changes * thorugh frontend for /trending * formatting error * FeedsThrottle * copilot changes * qualified format check * different trending scope for throttle * indentation * adding new tests plus env boundaries * migration name fix * migration name change * remove frontend from pr * model mismatch * removing the block from pieline * refactored validation from settings * utils delocate * duplicate test leftovers * BucketRepo + SnapshotRepo style * feed throttling for trending * response Caching, no snapshots * snapshots cleanup * snapshot removed from models * batch size of upsert changed to 10k(optimization) * removing api files out of the pr * validation updated * task scheduled for cleanup of activity bucket * excluding invalid ips in trending * imprvements in trending cronjob * review 2 * later fix, (keeping pr scope maintained) * ruff formatted --- docker/env_file_template | 9 +- greedybear/cronjobs/bucket_cleanup.py | 56 ++++ greedybear/cronjobs/bucket_utils.py | 59 ++++ greedybear/cronjobs/extraction/pipeline.py | 12 + greedybear/cronjobs/extraction/utils.py | 4 +- greedybear/cronjobs/repositories/__init__.py | 1 + .../cronjobs/repositories/trending_bucket.py | 73 +++++ greedybear/cronjobs/schedules.py | 6 + greedybear/cronjobs/trending.py | 112 ++++++++ .../migrations/0050_attackeractivitybucket.py | 32 +++ greedybear/models.py | 20 ++ greedybear/settings.py | 3 + greedybear/tasks.py | 6 + greedybear/utils.py | 13 + tests/api/views/test_feeds_throttle.py | 88 ------ .../test_extraction_pipeline_edge_cases.py | 51 ++++ tests/greedybear/cronjobs/test_trending.py | 253 ++++++++++++++++++ .../management/test_setup_schedules.py | 4 + tests/test_tasks.py | 1 + tests/test_trending_bucket_repository.py | 136 ++++++++++ tests/test_utils.py | 16 +- 21 files changed, 863 insertions(+), 92 deletions(-) create mode 100644 greedybear/cronjobs/bucket_cleanup.py create mode 100644 greedybear/cronjobs/bucket_utils.py create mode 100644 greedybear/cronjobs/repositories/trending_bucket.py create mode 100644 greedybear/cronjobs/trending.py create mode 100644 greedybear/migrations/0050_attackeractivitybucket.py delete mode 100644 tests/api/views/test_feeds_throttle.py create mode 100644 tests/greedybear/cronjobs/test_trending.py create mode 100644 tests/test_trending_bucket_repository.py diff --git a/docker/env_file_template b/docker/env_file_template index aa6bebc1a..fd4344766 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -84,6 +84,13 @@ ABUSEIPDB_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 + +# Trending attackers settings +# Max API window in minutes (must be >= 60 and multiple of 60) +TRENDING_MAX_WINDOW_MINUTES=22320 +# Bucket retention in hours (must be >= 1) +TRENDING_BUCKET_RETENTION_HOURS=744 # Optional feed license URL to include in API responses # If not set, no license information will be included in feeds @@ -93,4 +100,4 @@ FEEDS_LICENSE= # 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 -VITE_INTELOWL_URL= \ No newline at end of file +VITE_INTELOWL_URL= diff --git a/greedybear/cronjobs/bucket_cleanup.py b/greedybear/cronjobs/bucket_cleanup.py new file mode 100644 index 000000000..90c93d4f8 --- /dev/null +++ b/greedybear/cronjobs/bucket_cleanup.py @@ -0,0 +1,56 @@ +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone + +from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.repositories import TrendingBucketRepository + +DEFAULT_TRENDING_MAX_WINDOW_MINUTES = (24 * 31 * 60) // 2 +DEFAULT_TRENDING_BUCKET_RETENTION_HOURS = 24 * 31 + + +class TrendingBucketCleanupCron(Cronjob): + @staticmethod + def _positive_int_setting(name: str, value) -> int: + try: + parsed_value = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a positive integer, got {value!r}") from exc + + if parsed_value < 1: + raise ValueError(f"{name} must be >= 1, got {parsed_value}") + + return parsed_value + + def _validated_settings(self) -> tuple[int, int]: + max_window_minutes = self._positive_int_setting( + "TRENDING_MAX_WINDOW_MINUTES", + getattr(settings, "TRENDING_MAX_WINDOW_MINUTES", DEFAULT_TRENDING_MAX_WINDOW_MINUTES), + ) + if max_window_minutes < 60: + raise ValueError(f"TRENDING_MAX_WINDOW_MINUTES must be >= 60, got {max_window_minutes}") + if max_window_minutes % 60: + raise ValueError(f"TRENDING_MAX_WINDOW_MINUTES must be a multiple of 60, got {max_window_minutes}") + + retention_hours = self._positive_int_setting( + "TRENDING_BUCKET_RETENTION_HOURS", + getattr(settings, "TRENDING_BUCKET_RETENTION_HOURS", DEFAULT_TRENDING_BUCKET_RETENTION_HOURS), + ) + + retention_minutes = retention_hours * 60 + required_retention_minutes = 2 * max_window_minutes + if retention_minutes < required_retention_minutes: + raise ValueError( + "TRENDING_BUCKET_RETENTION_HOURS must retain at least two windows " + f"for TRENDING_MAX_WINDOW_MINUTES={max_window_minutes}: " + f"required >= {required_retention_minutes} minutes, got {retention_minutes}" + ) + + return max_window_minutes, retention_hours + + def run(self) -> None: + now = timezone.now().replace(minute=0, second=0, microsecond=0) + _, retention_hours = self._validated_settings() + cutoff = now - timedelta(hours=retention_hours) + TrendingBucketRepository().delete_older_than(cutoff) diff --git a/greedybear/cronjobs/bucket_utils.py b/greedybear/cronjobs/bucket_utils.py new file mode 100644 index 000000000..cdad51ddc --- /dev/null +++ b/greedybear/cronjobs/bucket_utils.py @@ -0,0 +1,59 @@ +import logging +from collections import Counter +from collections.abc import Iterable, Mapping +from datetime import datetime +from ipaddress import ip_address +from typing import Any + +from greedybear.cronjobs.extraction.utils import parse_timestamp +from greedybear.cronjobs.repositories import TrendingBucketRepository +from greedybear.utils import is_non_global_ip + +logger = logging.getLogger(__name__) + +BucketHit = Mapping[str, Any] +BucketKey = tuple[str, str, datetime] + + +def _bucket_start(timestamp: str) -> datetime: + parsed = parse_timestamp(timestamp) + return parsed.replace(minute=0, second=0, microsecond=0) + + +def _bucket_key_from_hit(hit: BucketHit) -> BucketKey | None: + attacker_ip = hit.get("src_ip") + feed_type = hit.get("type") + timestamp = hit.get("@timestamp") + if not attacker_ip or not feed_type or not timestamp: + return None + + normalized_ip = str(attacker_ip) + try: + parsed_ip = ip_address(normalized_ip) + except ValueError: + return None + + if is_non_global_ip(parsed_ip): + return None + + try: + return normalized_ip, str(feed_type).lower(), _bucket_start(timestamp) + except Exception: + return None + + +def update_activity_buckets_from_hits(hits: Iterable[BucketHit]) -> int: + counters: Counter[BucketKey] = Counter() + for hit in hits: + key = _bucket_key_from_hit(hit) + if key is not None: + counters[key] += 1 + + if not counters: + return 0 + + try: + return TrendingBucketRepository().upsert_bucket_counts(counters) + except Exception as exc: + logger.error("Failed to update activity buckets from hits for current chunk: %s", exc, exc_info=True) + return 0 diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py index f434bd83d..c85a1ee85 100644 --- a/greedybear/cronjobs/extraction/pipeline.py +++ b/greedybear/cronjobs/extraction/pipeline.py @@ -3,6 +3,7 @@ from django.core.cache import caches +from greedybear.cronjobs.bucket_utils import update_activity_buckets_from_hits from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory from greedybear.cronjobs.repositories import ( ElasticRepository, @@ -57,6 +58,7 @@ def execute(self) -> int: Number of IOC records processed. """ ioc_record_count = 0 + bucket_update_count = 0 factory = ExtractionStrategyFactory(self.ioc_repo, self.sensor_repo) # 1. Search in chunks @@ -65,6 +67,8 @@ def execute(self) -> int: ioc_records = [] hits_by_honeypot = defaultdict(list) + bucket_update_count += update_activity_buckets_from_hits(chunk) + # 2. Group by honeypot self.log.info("Grouping hits by honeypot type") for hit in chunk: @@ -117,4 +121,12 @@ def execute(self) -> int: except ValueError: shared_cache.set("asn_feeds_version", 2, timeout=None) + if bucket_update_count > 0: + self.log.info("Invalidating feeds trending cache") + shared_cache = caches["django-q"] + try: + shared_cache.incr("trending_feeds_version") + except ValueError: + shared_cache.set("trending_feeds_version", 2, timeout=None) + return ioc_record_count diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index 62e2ca961..ed96cc696 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -9,7 +9,7 @@ from greedybear.cronjobs.repositories import ASRepository from greedybear.enums import IpReputation from greedybear.models import IOC, FireHolList, MassScanner -from greedybear.utils import get_ioc_type, parse_timestamp +from greedybear.utils import get_ioc_type, is_non_global_ip, parse_timestamp def normalize_credential_field(value: object, max_length: int = 256) -> str: @@ -135,7 +135,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: as_repository = ASRepository() # single instance for this batch for ip, hits in hits_by_ip.items(): extracted_ip = ip_address(ip) - if extracted_ip.is_loopback or extracted_ip.is_private or extracted_ip.is_multicast or extracted_ip.is_link_local or extracted_ip.is_reserved: + if is_non_global_ip(extracted_ip): continue firehol_categories = get_firehol_categories(ip, extracted_ip, firehol_exact_map, cidr_entries) diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py index 337eac0c8..39d9bf07a 100644 --- a/greedybear/cronjobs/repositories/__init__.py +++ b/greedybear/cronjobs/repositories/__init__.py @@ -7,3 +7,4 @@ from greedybear.cronjobs.repositories.sensor import * from greedybear.cronjobs.repositories.tag import * from greedybear.cronjobs.repositories.tor import * +from greedybear.cronjobs.repositories.trending_bucket import * diff --git a/greedybear/cronjobs/repositories/trending_bucket.py b/greedybear/cronjobs/repositories/trending_bucket.py new file mode 100644 index 000000000..5b7f9101c --- /dev/null +++ b/greedybear/cronjobs/repositories/trending_bucket.py @@ -0,0 +1,73 @@ +from collections import Counter +from collections.abc import Iterable +from datetime import datetime + +from django.db import connection +from django.db.models import Sum + +from greedybear.models import AttackerActivityBucket + +BucketKey = tuple[str, str, datetime] + + +class TrendingBucketRepository: + """Repository for reading and writing aggregated attacker activity buckets.""" + + UPSERT_BATCH_SIZE = 10_000 + _UPSERT_VALUE_PLACEHOLDER = "(%s, %s, %s, %s)" + + @classmethod + def _build_upsert_query(cls, quoted_table_name: str, row_count: int) -> str: + values_sql = ",".join([cls._UPSERT_VALUE_PLACEHOLDER] * row_count) + return f""" + INSERT INTO {quoted_table_name} (attacker_ip, feed_type, bucket_start, interaction_count) + VALUES {values_sql} + ON CONFLICT (attacker_ip, feed_type, bucket_start) + DO UPDATE + SET interaction_count = {quoted_table_name}.interaction_count + EXCLUDED.interaction_count + """ + + @staticmethod + def _build_upsert_params(batch: list[tuple[BucketKey, int]]) -> list[object]: + params: list[object] = [] + for (attacker_ip, feed_type, bucket_start), interaction_count in batch: + params.extend((attacker_ip, feed_type, bucket_start, interaction_count)) + return params + + @staticmethod + def _normalize_feed_types(feed_types: str | Iterable[str]) -> list[str]: + if isinstance(feed_types, str): + return [feed_types] + return list(feed_types) + + def upsert_bucket_counts(self, counters: Counter[BucketKey]) -> int: + """Insert or increment bucket counts in batches and return the number of unique keys.""" + if not counters: + return 0 + + table_name = AttackerActivityBucket._meta.db_table + quoted_table_name = connection.ops.quote_name(table_name) + counter_items = list(counters.items()) + with connection.cursor() as cursor: + for batch_start in range(0, len(counter_items), self.UPSERT_BATCH_SIZE): + batch = counter_items[batch_start : batch_start + self.UPSERT_BATCH_SIZE] + query = self._build_upsert_query(quoted_table_name, len(batch)) + params = self._build_upsert_params(batch) + cursor.execute(query, params) + + return len(counters) + + def get_counts_in_window(self, window_start: datetime, window_end: datetime, feed_types: str | Iterable[str]) -> dict[str, int]: + """Return summed interaction counts per attacker IP inside the requested time window.""" + queryset = AttackerActivityBucket.objects.filter(bucket_start__gte=window_start, bucket_start__lt=window_end) + normalized_feed_types = self._normalize_feed_types(feed_types) + + if "all" not in normalized_feed_types: + queryset = queryset.filter(feed_type__in=normalized_feed_types) + + return dict(queryset.values("attacker_ip").annotate(total=Sum("interaction_count")).values_list("attacker_ip", "total")) + + def delete_older_than(self, cutoff: datetime) -> int: + """Delete buckets older than the cutoff and return Django's reported delete count.""" + deleted_count, _ = AttackerActivityBucket.objects.filter(bucket_start__lt=cutoff).delete() + return deleted_count diff --git a/greedybear/cronjobs/schedules.py b/greedybear/cronjobs/schedules.py index 03271a232..eb9615767 100644 --- a/greedybear/cronjobs/schedules.py +++ b/greedybear/cronjobs/schedules.py @@ -48,6 +48,12 @@ def setup_schedules(): "func": "greedybear.tasks.monitor_logs", "cron": "7 * * * *", }, + # Trending Buckets Cleanup: Hourly at :12 + { + "name": "clean_up_trending_buckets", + "func": "greedybear.tasks.clean_up_trending_buckets", + "cron": "12 * * * *", + }, # Cluster Commands: Daily at 01:07 { "name": "cluster_commands", diff --git a/greedybear/cronjobs/trending.py b/greedybear/cronjobs/trending.py new file mode 100644 index 000000000..46d91f1a6 --- /dev/null +++ b/greedybear/cronjobs/trending.py @@ -0,0 +1,112 @@ +from collections.abc import Mapping + +AttackerCounts = Mapping[str, int] +AttackerRank = int | None +RankedAttacker = dict[str, str | int | float | None] + +UNRANKED_ATTACKER_SORT_ORDER = 10**9 + + +def _rank_map(sorted_counts: list[tuple[str, int]]) -> dict[str, int]: + return {attacker_ip: rank for rank, (attacker_ip, _) in enumerate(sorted_counts, start=1)} + + +def growth_score(current_count: int, previous_count: int) -> float: + if previous_count == 0: + return round(float(current_count), 4) + return round((current_count - previous_count) / previous_count, 4) + + +def rank_delta(current_rank: int | None, previous_rank: int | None) -> int | None: + if current_rank is not None and previous_rank is not None: + return previous_rank - current_rank + if current_rank is None and previous_rank is not None: + return -previous_rank + return None + + +def attacker_sort_tuple( + attacker_ip: str, + current_rank: AttackerRank, + current_count: int, + previous_count: int, +) -> tuple[bool, int, int, int, str]: + return ( + current_rank is None, + current_rank or UNRANKED_ATTACKER_SORT_ORDER, + -(current_count - previous_count), + -previous_count, + attacker_ip, + ) + + +def _ranked_attacker( + attacker_ip: str, + current_rank: AttackerRank, + previous_rank: AttackerRank, + current_count: int, + previous_count: int, +) -> RankedAttacker: + return { + "attacker_ip": attacker_ip, + "current_interactions": current_count, + "previous_interactions": previous_count, + "interaction_delta": current_count - previous_count, + "growth_score": growth_score(current_count, previous_count), + "current_rank": current_rank, + "previous_rank": previous_rank, + "rank_delta": rank_delta(current_rank, previous_rank), + } + + +def build_ranked_attackers(current_counts: AttackerCounts, previous_counts: AttackerCounts, limit: int) -> list[RankedAttacker]: + sorted_current = sorted(current_counts.items(), key=lambda item: (-item[1], item[0])) + sorted_previous = sorted(previous_counts.items(), key=lambda item: (-item[1], item[0])) + + current_ranks = _rank_map(sorted_current) + previous_ranks = _rank_map(sorted_previous) + + candidate_ips = {ip for ip, _ in sorted_current[:limit]} + candidate_ips |= {ip for ip, _ in sorted_previous[:limit]} + + previous_rank_offset = 1 + + def _effective_rank(attacker_ip: str) -> int: + current_rank = current_ranks.get(attacker_ip) + if current_rank is not None: + return current_rank + + previous_rank = previous_ranks.get(attacker_ip) + if previous_rank is not None: + return previous_rank + previous_rank_offset + + return UNRANKED_ATTACKER_SORT_ORDER + + sorted_ips = sorted( + candidate_ips, + key=lambda attacker_ip: ( + _effective_rank(attacker_ip), + -(current_counts.get(attacker_ip, 0) - previous_counts.get(attacker_ip, 0)), + -previous_counts.get(attacker_ip, 0), + attacker_ip, + ), + )[:limit] + + return [ + _ranked_attacker( + attacker_ip, + current_ranks.get(attacker_ip), + previous_ranks.get(attacker_ip), + current_counts.get(attacker_ip, 0), + previous_counts.get(attacker_ip, 0), + ) + for attacker_ip in sorted_ips + ] + + +def validate_window_minutes(window_minutes: int, max_window_minutes: int) -> int: + if window_minutes > max_window_minutes: + raise ValueError(f"window_minutes cannot be greater than {max_window_minutes}") + if window_minutes % 60 != 0: + raise ValueError("window_minutes must be a multiple of 60") + return window_minutes diff --git a/greedybear/migrations/0050_attackeractivitybucket.py b/greedybear/migrations/0050_attackeractivitybucket.py new file mode 100644 index 000000000..2ffe55f9f --- /dev/null +++ b/greedybear/migrations/0050_attackeractivitybucket.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.12 on 2026-03-20 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("greedybear", "0049_rename_generalhoneypot_honeypot_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="AttackerActivityBucket", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("attacker_ip", models.GenericIPAddressField()), + ("feed_type", models.CharField(max_length=32)), + ("bucket_start", models.DateTimeField()), + ("interaction_count", models.IntegerField(default=0)), + ], + options={ + "indexes": [ + models.Index(fields=["bucket_start"], name="greedybear__bucket__ce4aaf_idx"), + models.Index(fields=["feed_type", "bucket_start"], name="greedybear__feed_ty_84e90b_idx"), + models.Index(fields=["attacker_ip", "bucket_start"], name="greedybear__attacke_910f2f_idx"), + ], + "constraints": [ + models.UniqueConstraint(fields=("attacker_ip", "feed_type", "bucket_start"), name="unique_attacker_activity_bucket"), + ], + }, + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 4156634c9..be153518e 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -305,3 +305,23 @@ class ShareToken(models.Model): def __str__(self): status = "revoked" if self.revoked else "active" return f"ShareToken({self.token_hash[:12]}… [{status}])" + + +class AttackerActivityBucket(models.Model): + attacker_ip = models.GenericIPAddressField() + feed_type = models.CharField(max_length=32) + bucket_start = models.DateTimeField() + interaction_count = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["attacker_ip", "feed_type", "bucket_start"], name="unique_attacker_activity_bucket"), + ] + indexes = [ + models.Index(fields=["bucket_start"]), + models.Index(fields=["feed_type", "bucket_start"]), + models.Index(fields=["attacker_ip", "bucket_start"]), + ] + + def __str__(self): + return f"{self.attacker_ip} [{self.feed_type}] @ {self.bucket_start} ({self.interaction_count})" diff --git a/greedybear/settings.py b/greedybear/settings.py index e1d9b60f2..64888b69d 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -465,6 +465,9 @@ COWRIE_SESSION_RETENTION = int(os.environ.get("COWRIE_SESSION_RETENTION", "365")) COMMAND_SEQUENCE_RETENTION = int(os.environ.get("COMMAND_SEQUENCE_RETENTION", "365")) +TRENDING_MAX_WINDOW_MINUTES = int(os.environ.get("TRENDING_MAX_WINDOW_MINUTES", str((24 * 31 * 60) // 2))) +TRENDING_BUCKET_RETENTION_HOURS = int(os.environ.get("TRENDING_BUCKET_RETENTION_HOURS", str(24 * 31))) + THREATFOX_API_KEY = os.environ.get("THREATFOX_API_KEY", "") ABUSEIPDB_API_KEY = os.environ.get("ABUSEIPDB_API_KEY", "") diff --git a/greedybear/tasks.py b/greedybear/tasks.py index 0a26ffc02..8bf906631 100644 --- a/greedybear/tasks.py +++ b/greedybear/tasks.py @@ -105,3 +105,9 @@ def extract_spamhaus_drop(): from greedybear.cronjobs.spamhaus_drop import SpamhausDropCron SpamhausDropCron().execute() + + +def clean_up_trending_buckets(): + from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron + + TrendingBucketCleanupCron().execute() diff --git a/greedybear/utils.py b/greedybear/utils.py index aeb5f310a..b87056ae5 100644 --- a/greedybear/utils.py +++ b/greedybear/utils.py @@ -27,6 +27,19 @@ def is_ip_address(string: str) -> bool: return True +def is_non_global_ip(value) -> bool: + """ + Return True when an IP should be treated as non-global/unroutable. + + Args: + value: IPv4Address or IPv6Address from ipaddress module. + + Returns: + bool: True for loopback/private/multicast/link-local/reserved addresses. + """ + return value.is_loopback or value.is_private or value.is_multicast or value.is_link_local or value.is_reserved + + def is_valid_domain(string: str) -> bool: """ Validate if a string is a safe domain name for use in STIX patterns. diff --git a/tests/api/views/test_feeds_throttle.py b/tests/api/views/test_feeds_throttle.py deleted file mode 100644 index c69e9c535..000000000 --- a/tests/api/views/test_feeds_throttle.py +++ /dev/null @@ -1,88 +0,0 @@ -from unittest.mock import patch - -from django.core.cache import cache -from rest_framework import status -from rest_framework.test import APIClient - -from tests import CustomTestCase - - -class FeedsThrottleTestCase(CustomTestCase): - """Tests that rate limiting is applied to feeds endpoints.""" - - def setUp(self): - super().setUp() - cache.clear() - self.client = APIClient() - self.client.force_authenticate(user=self.superuser) - - @patch("api.throttles.FeedsAdvancedThrottle.get_rate", return_value="1/minute") - def test_feeds_advanced_throttled(self, mock_rate): - """Verify feeds_advanced returns 429 after exceeding the rate limit.""" - response = self.client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - @patch("api.throttles.FeedsAdvancedThrottle.get_rate", return_value="1/minute") - def test_feeds_asn_throttled(self, mock_rate): - """Verify feeds_asn returns 429 after exceeding the rate limit.""" - response = self.client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - @patch("api.throttles.FeedsThrottle.get_rate", return_value="1/minute") - def test_feeds_pagination_throttled(self, mock_rate): - """Verify feeds_pagination returns 429 after exceeding the rate limit.""" - response = self.client.get("/api/feeds/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get("/api/feeds/") - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - @patch("api.throttles.FeedsThrottle.get_rate", return_value="1/minute") - def test_feeds_legacy_throttled(self, mock_rate): - """Verify legacy feeds endpoint returns 429 after exceeding the rate limit.""" - url = "/api/feeds/all/all/recent.json" - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - def test_feeds_advanced_within_limit(self): - """Verify feeds_advanced succeeds when within the rate limit.""" - response = self.client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_asn_within_limit(self): - """Verify feeds_asn succeeds when within the rate limit.""" - response = self.client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_unauthenticated_access(self): - """Verify public feeds endpoints are accessible without authentication.""" - client = APIClient() - response = client.get("/api/feeds/all/all/recent.json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_pagination_unauthenticated_access(self): - """Verify public feeds pagination endpoint is accessible without authentication.""" - client = APIClient() - response = client.get("/api/feeds/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_advanced_unauthenticated_rejected(self): - """Verify authenticated feeds_advanced endpoint rejects unauthenticated requests.""" - client = APIClient() - response = client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_feeds_asn_unauthenticated_rejected(self): - """Verify authenticated feeds_asn endpoint rejects unauthenticated requests.""" - client = APIClient() - response = client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py index b9dcdbecd..d864a8cff 100644 --- a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py +++ b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py @@ -60,6 +60,57 @@ def test_partial_strategy_success(self, mock_factory, mock_scores): # Scoring should be called with successful IOCs mock_scores.return_value.score_only.assert_called_once() + @patch("greedybear.cronjobs.extraction.pipeline.update_activity_buckets_from_hits", return_value=0) + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_factory, mock_scores, _mock_update_activity): + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "2.2.2.2", "type": "SuccessHoneypot"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.return_value = True + + mock_success = MagicMock() + mock_success.ioc_records = [self._create_mock_ioc("2.2.2.2")] + mock_factory.return_value.get_strategy.return_value = mock_success + + result = pipeline.execute() + + self.assertEqual(result, 1) + mock_success.extract_from_hits.assert_called_once() + mock_scores.return_value.score_only.assert_called_once() + _mock_update_activity.assert_called_once() + + @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.update_activity_buckets_from_hits", return_value=2) + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_scores, _mock_update_activity, mock_caches): + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "2.2.2.2", "type": "SuccessHoneypot"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.return_value = False + + shared_cache = MagicMock() + mock_caches.__getitem__.return_value = shared_cache + + result = pipeline.execute() + + self.assertEqual(result, 0) + _mock_update_activity.assert_called_once() + shared_cache.incr.assert_called_once_with("trending_feeds_version") + mock_scores.return_value.score_only.assert_not_called() + mock_factory.return_value.get_strategy.assert_not_called() + class TestLargeBatches(E2ETestCase): """Tests for large batch processing using REAL strategies.""" diff --git a/tests/greedybear/cronjobs/test_trending.py b/tests/greedybear/cronjobs/test_trending.py new file mode 100644 index 000000000..7d97dc4c6 --- /dev/null +++ b/tests/greedybear/cronjobs/test_trending.py @@ -0,0 +1,253 @@ +from datetime import datetime +from unittest.mock import patch + +from django.test import SimpleTestCase, override_settings + +from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron +from greedybear.cronjobs.bucket_utils import update_activity_buckets_from_hits +from greedybear.cronjobs.repositories.trending_bucket import TrendingBucketRepository +from greedybear.cronjobs.trending import ( + attacker_sort_tuple, + build_ranked_attackers, + growth_score, + rank_delta, + validate_window_minutes, +) +from greedybear.models import AttackerActivityBucket +from tests import CustomTestCase + + +class TrendingHelpersTestCase(SimpleTestCase): + def test_growth_score(self): + self.assertEqual(growth_score(10, 0), 10.0) + self.assertEqual(growth_score(12, 8), 0.5) + self.assertEqual(growth_score(8, 12), -0.3333) + + def test_rank_delta(self): + self.assertEqual(rank_delta(2, 5), 3) + self.assertEqual(rank_delta(None, 5), -5) + self.assertIsNone(rank_delta(3, None)) + self.assertIsNone(rank_delta(None, None)) + + def test_attacker_sort_tuple_prefers_ranked_entries(self): + ranked = attacker_sort_tuple("1.1.1.1", 2, 10, 7) + unranked = attacker_sort_tuple("2.2.2.2", None, 10, 7) + self.assertLess(ranked, unranked) + + def test_build_ranked_attackers_uses_current_and_previous_candidates(self): + current_counts = {"1.1.1.1": 12, "2.2.2.2": 10, "3.3.3.3": 8} + previous_counts = {"9.9.9.9": 30, "1.1.1.1": 9} + + ranked = build_ranked_attackers(current_counts, previous_counts, limit=3) + + self.assertEqual(len(ranked), 3) + self.assertEqual(ranked[0]["attacker_ip"], "1.1.1.1") + returned_ips = {entry["attacker_ip"] for entry in ranked} + self.assertIn("9.9.9.9", returned_ips) + self.assertEqual(next(entry for entry in ranked if entry["attacker_ip"] == "9.9.9.9")["current_rank"], None) + + +class ValidateWindowMinutesTestCase(SimpleTestCase): + def test_validate_window_minutes_returns_valid_value(self): + self.assertEqual(validate_window_minutes(120, 240), 120) + + def test_validate_window_minutes_raises_when_above_max(self): + with self.assertRaisesMessage(ValueError, "window_minutes cannot be greater than 240"): + validate_window_minutes(300, 240) + + def test_validate_window_minutes_raises_when_not_multiple_of_60(self): + with self.assertRaisesMessage(ValueError, "window_minutes must be a multiple of 60"): + validate_window_minutes(90, 240) + + +class UpdateActivityBucketsFromHitsTestCase(CustomTestCase): + def test_upsert_increments_existing_bucket_and_creates_missing_bucket(self): + AttackerActivityBucket.objects.create( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=3, + ) + + unique_keys = update_activity_buckets_from_hits( + [ + {"src_ip": "1.1.1.1", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "1.1.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:50:00"}, + {"src_ip": "2.2.2.2", "type": "Heralding", "@timestamp": "2026-03-20T09:10:00"}, + ] + ) + + self.assertEqual(unique_keys, 2) + + existing_bucket = AttackerActivityBucket.objects.get( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + ) + self.assertEqual(existing_bucket.interaction_count, 5) + + created_bucket = AttackerActivityBucket.objects.get( + attacker_ip="2.2.2.2", + feed_type="heralding", + bucket_start=datetime(2026, 3, 20, 9, 0), + ) + self.assertEqual(created_bucket.interaction_count, 1) + + def test_invalid_hits_are_ignored(self): + unique_keys = update_activity_buckets_from_hits( + [ + {"src_ip": "", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "999.999.999.999", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "3.3.3.3", "type": "", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "3.3.3.3", "type": "cowrie"}, + ] + ) + + self.assertEqual(unique_keys, 0) + self.assertEqual(AttackerActivityBucket.objects.count(), 0) + + def test_invalid_timestamp_is_ignored(self): + unique_keys = update_activity_buckets_from_hits( + [ + {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "not-a-timestamp"}, + ] + ) + self.assertEqual(unique_keys, 0) + self.assertEqual(AttackerActivityBucket.objects.count(), 0) + + def test_non_global_ip_hits_are_ignored(self): + unique_keys = update_activity_buckets_from_hits( + [ + {"src_ip": "10.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "127.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "224.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "169.254.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "240.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + ] + ) + + self.assertEqual(unique_keys, 1) + self.assertEqual(AttackerActivityBucket.objects.count(), 1) + self.assertTrue(AttackerActivityBucket.objects.filter(attacker_ip="8.8.8.8").exists()) + + def test_global_ipv6_hit_is_counted(self): + unique_keys = update_activity_buckets_from_hits( + [ + {"src_ip": "2001:4860:4860::8888", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, + ] + ) + self.assertEqual(unique_keys, 1) + self.assertTrue( + AttackerActivityBucket.objects.filter( + attacker_ip="2001:4860:4860::8888", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + ).exists() + ) + + def test_upsert_uses_multiple_batches_for_large_counter_sets(self): + counters = { + ("10.0.0.1", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.2", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.3", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.4", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.5", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + } + + repository = TrendingBucketRepository() + with patch.object(TrendingBucketRepository, "UPSERT_BATCH_SIZE", 2): + with patch("greedybear.cronjobs.repositories.trending_bucket.connection.cursor") as mock_cursor_factory: + mock_cursor = mock_cursor_factory.return_value.__enter__.return_value + inserted = repository.upsert_bucket_counts(counters) + + self.assertEqual(inserted, 5) + self.assertEqual(mock_cursor.execute.call_count, 3) + + @patch("greedybear.cronjobs.bucket_utils.TrendingBucketRepository.upsert_bucket_counts", side_effect=Exception("db down")) + def test_upsert_failure_returns_zero(self, mock_upsert): + unique_keys = update_activity_buckets_from_hits([{"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}]) + self.assertEqual(unique_keys, 0) + mock_upsert.assert_called_once() + + +class TrendingBucketCleanupCronTestCase(CustomTestCase): + def setUp(self): + super().setUp() + self.cron = TrendingBucketCleanupCron() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=2, + TRENDING_MAX_WINDOW_MINUTES=60, + ) + def test_run_applies_bucket_retention_cleanup(self): + AttackerActivityBucket.objects.bulk_create( + [ + AttackerActivityBucket( + attacker_ip="2.2.2.2", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 7, 0), + interaction_count=1, + ), + AttackerActivityBucket( + attacker_ip="3.3.3.3", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=1, + ), + ] + ) + + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + self.cron.run() + + self.assertFalse(AttackerActivityBucket.objects.filter(attacker_ip="2.2.2.2").exists()) + self.assertTrue(AttackerActivityBucket.objects.filter(attacker_ip="3.3.3.3").exists()) + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=0, + ) + def test_run_raises_on_invalid_retention_hours(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=1, + TRENDING_MAX_WINDOW_MINUTES=60, + ) + def test_run_raises_when_retention_cannot_cover_two_windows(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=1, + TRENDING_MAX_WINDOW_MINUTES=120, + ) + def test_run_raises_when_max_window_exceeds_retention_horizon(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=4, + TRENDING_MAX_WINDOW_MINUTES=59, + ) + def test_run_raises_when_max_window_below_60(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=4, + TRENDING_MAX_WINDOW_MINUTES=130, + ) + def test_run_raises_when_max_window_not_multiple_of_60(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + def test_positive_int_setting_rejects_non_numeric(self): + with self.assertRaisesMessage(ValueError, "TRENDING_BUCKET_RETENTION_HOURS must be a positive integer"): + self.cron._positive_int_setting("TRENDING_BUCKET_RETENTION_HOURS", "abc") diff --git a/tests/greedybear/management/test_setup_schedules.py b/tests/greedybear/management/test_setup_schedules.py index 78b5384da..b46297e76 100644 --- a/tests/greedybear/management/test_setup_schedules.py +++ b/tests/greedybear/management/test_setup_schedules.py @@ -26,6 +26,10 @@ def test_extraction_interval_10(self, mock_schedule): self.assertEqual(extract_call[1]["defaults"]["schedule_type"], Schedule.CRON) self.assertEqual(extract_call[1]["defaults"]["cron"], "*/10 * * * *") + trending_cleanup_call = next(c for c in calls if c[1]["name"] == "clean_up_trending_buckets") + self.assertEqual(trending_cleanup_call[1]["defaults"]["schedule_type"], Schedule.CRON) + self.assertEqual(trending_cleanup_call[1]["defaults"]["cron"], "12 * * * *") + @patch("greedybear.cronjobs.schedules.Schedule") @override_settings(EXTRACTION_INTERVAL=60) def test_extraction_interval_60_clamps_minute(self, mock_schedule): diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 7c73287de..70afc8dec 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -71,6 +71,7 @@ def test_simple_wrapper_tasks(self): ("monitor_honeypots", "greedybear.cronjobs.monitor_honeypots.MonitorHoneypots"), ("monitor_logs", "greedybear.cronjobs.monitor_logs.MonitorLogs"), ("clean_up_db", "greedybear.cronjobs.cleanup.CleanUp"), + ("clean_up_trending_buckets", "greedybear.cronjobs.bucket_cleanup.TrendingBucketCleanupCron"), ("get_mass_scanners", "greedybear.cronjobs.mass_scanners.MassScannersCron"), ("get_whatsmyip", "greedybear.cronjobs.whatsmyip.WhatsMyIPCron"), ("extract_firehol_lists", "greedybear.cronjobs.firehol.FireHolCron"), diff --git a/tests/test_trending_bucket_repository.py b/tests/test_trending_bucket_repository.py new file mode 100644 index 000000000..fd1ff8a93 --- /dev/null +++ b/tests/test_trending_bucket_repository.py @@ -0,0 +1,136 @@ +from collections import Counter +from datetime import datetime + +from greedybear.cronjobs.repositories.trending_bucket import TrendingBucketRepository +from greedybear.models import AttackerActivityBucket +from tests import CustomTestCase + + +class TestTrendingBucketRepository(CustomTestCase): + def setUp(self): + super().setUp() + self.repo = TrendingBucketRepository() + + def test_build_upsert_query_contains_expected_placeholders(self): + query = self.repo._build_upsert_query('"greedybear_attackeractivitybucket"', 3) + + self.assertIn("INSERT INTO", query) + self.assertEqual(query.count("(%s, %s, %s, %s)"), 3) + self.assertIn("ON CONFLICT (attacker_ip, feed_type, bucket_start)", query) + + def test_build_upsert_params_flattens_batch(self): + batch = [ + (("1.1.1.1", "cowrie", datetime(2026, 3, 20, 9, 0)), 2), + (("2.2.2.2", "heralding", datetime(2026, 3, 20, 10, 0)), 4), + ] + + params = self.repo._build_upsert_params(batch) + + self.assertEqual( + params, + [ + "1.1.1.1", + "cowrie", + datetime(2026, 3, 20, 9, 0), + 2, + "2.2.2.2", + "heralding", + datetime(2026, 3, 20, 10, 0), + 4, + ], + ) + + def test_normalize_feed_types_from_string(self): + self.assertEqual(self.repo._normalize_feed_types("cowrie"), ["cowrie"]) + + def test_normalize_feed_types_from_iterable(self): + self.assertEqual(self.repo._normalize_feed_types(("cowrie", "heralding")), ["cowrie", "heralding"]) + + def test_upsert_bucket_counts_returns_zero_for_empty_counter(self): + self.assertEqual(self.repo.upsert_bucket_counts(Counter()), 0) + + def test_get_counts_in_window_filters_feed_types(self): + AttackerActivityBucket.objects.bulk_create( + [ + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=4, + ), + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="heralding", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=2, + ), + AttackerActivityBucket( + attacker_ip="2.2.2.2", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=3, + ), + ] + ) + + counts = self.repo.get_counts_in_window( + window_start=datetime(2026, 3, 20, 9, 0), + window_end=datetime(2026, 3, 20, 10, 0), + feed_types=["cowrie"], + ) + + self.assertEqual(counts, {"1.1.1.1": 4, "2.2.2.2": 3}) + + def test_get_counts_in_window_with_all_feed_type_combines_totals(self): + AttackerActivityBucket.objects.bulk_create( + [ + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=4, + ), + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="heralding", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=2, + ), + ] + ) + + counts = self.repo.get_counts_in_window( + window_start=datetime(2026, 3, 20, 9, 0), + window_end=datetime(2026, 3, 20, 10, 0), + feed_types="all", + ) + + self.assertEqual(counts, {"1.1.1.1": 6}) + + def test_get_counts_in_window_returns_empty_when_no_matches(self): + counts = self.repo.get_counts_in_window( + window_start=datetime(2026, 3, 20, 9, 0), + window_end=datetime(2026, 3, 20, 10, 0), + feed_types=["cowrie"], + ) + self.assertEqual(counts, {}) + + def test_delete_older_than_removes_only_older_rows(self): + old_bucket = AttackerActivityBucket.objects.create( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 7, 0), + interaction_count=1, + ) + fresh_bucket = AttackerActivityBucket.objects.create( + attacker_ip="2.2.2.2", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=1, + ) + + deleted_count = self.repo.delete_older_than(datetime(2026, 3, 20, 8, 0)) + + self.assertEqual(deleted_count, 1) + self.assertFalse(AttackerActivityBucket.objects.filter(id=old_bucket.id).exists()) + self.assertTrue(AttackerActivityBucket.objects.filter(id=fresh_bucket.id).exists()) diff --git a/tests/test_utils.py b/tests/test_utils.py index f7bd78357..10be6b6dc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,11 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. +from ipaddress import ip_address + from django.test import SimpleTestCase -from greedybear.utils import is_ip_address, is_sha256hash, is_valid_domain +from greedybear.utils import is_ip_address, is_non_global_ip, is_sha256hash, is_valid_domain class UtilsTestCase(SimpleTestCase): @@ -45,3 +47,15 @@ def test_is_sha256hash(self): self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8555")) # 65 chars self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4g49b934ca495991b7852b855")) # Invalid char 'g' self.assertFalse(is_sha256hash("")) + + def test_is_non_global_ip(self): + self.assertTrue(is_non_global_ip(ip_address("127.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("10.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("169.254.1.1"))) + self.assertTrue(is_non_global_ip(ip_address("224.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("240.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("::1"))) + self.assertTrue(is_non_global_ip(ip_address("fc00::1"))) + + self.assertFalse(is_non_global_ip(ip_address("8.8.8.8"))) + self.assertFalse(is_non_global_ip(ip_address("2001:4860:4860::8888"))) From ac37b636a187b35bb4f4532701fdd4f23e6825ba Mon Sep 17 00:00:00 2001 From: Krishna Awasthi <140143710+opbot-xd@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:25:17 +0530 Subject: [PATCH 02/22] feat: add ?reason=param to feeds_share and token list endpoint. Closes #1224 (#1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add ?reason= param to feeds_share and token list endpoint - Accept optional ?reason= query parameter on GET /api/feeds/share. The value is persisted in ShareToken.reason (truncated to 256 chars). Existing tokens are not overwritten (get_or_create semantics). - Add GET /api/feeds/tokens/ (authenticated) — returns only the calling user's tokens with safe metadata: hash_prefix (12 hex chars), reason, created_at, revoked, revoked_at. The raw token is never exposed. - Add comprehensive tests covering both features (13 test cases). Closes #1224 * fix: ensure idempotent feed token generation and improve logging and query efficiency in share views --- api/urls.py | 2 + api/views/feeds.py | 48 ++++++- tests/api/views/test_feeds_share_view.py | 157 +++++++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/api/views/test_feeds_share_view.py diff --git a/api/urls.py b/api/urls.py index ef5ed5a8e..34c15dfb6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -15,6 +15,7 @@ feeds_pagination, feeds_revoke, feeds_share, + feeds_tokens, general_honeypot_list, health_view, news_view, @@ -30,6 +31,7 @@ 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), diff --git a/api/views/feeds.py b/api/views/feeds.py index dec97ec18..11fbe1127 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -41,6 +41,14 @@ "prioritize", ] +_TOKEN_LIST_FIELDS = ( + "token_hash", + "reason", + "created_at", + "revoked", + "revoked_at", +) + @api_view([GET]) @throttle_classes([FeedsThrottle]) @@ -219,20 +227,27 @@ def feeds_share(request): 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. """ - logger.info(f"request /api/feeds/share with params: {request.query_params}") + 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}) + ShareToken.objects.get_or_create( + token_hash=token_hash, + defaults={"user": request.user, "reason": reason}, + ) host = request.build_absolute_uri("/") share_url = f"{host}api/feeds/consume/{token}" @@ -323,3 +338,32 @@ def feeds_revoke(request, token): 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) diff --git a/tests/api/views/test_feeds_share_view.py b/tests/api/views/test_feeds_share_view.py new file mode 100644 index 000000000..73009eee9 --- /dev/null +++ b/tests/api/views/test_feeds_share_view.py @@ -0,0 +1,157 @@ +from rest_framework.test import APIClient + +from greedybear.models import ShareToken +from tests import CustomTestCase + + +class FeedsShareReasonTestCase(CustomTestCase): + """Tests for the optional ?reason= parameter on GET /api/feeds/share.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + self.client.force_authenticate(user=self.superuser) + + def test_share_without_reason(self): + """When no reason is supplied, ShareToken.reason defaults to empty string.""" + response = self.client.get("/api/feeds/share") + self.assertEqual(response.status_code, 200) + + token_hash = self._hash_from_url(response.json()["url"]) + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(share_token.reason, "") + + def test_share_with_reason(self): + """When ?reason=... is supplied, it is persisted on the ShareToken.""" + response = self.client.get("/api/feeds/share?reason=monthly+report") + self.assertEqual(response.status_code, 200) + + token_hash = self._hash_from_url(response.json()["url"]) + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(share_token.reason, "monthly report") + + def test_share_reason_truncated_at_256_chars(self): + """A reason longer than 256 characters is silently truncated.""" + long_reason = "x" * 300 + response = self.client.get(f"/api/feeds/share?reason={long_reason}") + self.assertEqual(response.status_code, 200) + + token_hash = self._hash_from_url(response.json()["url"]) + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(len(share_token.reason), 256) + + def test_share_reason_only_set_on_create(self): + """get_or_create: when the same token already exists, reason is NOT overwritten.""" + r1 = self.client.get("/api/feeds/share?reason=first") + self.assertEqual(r1.status_code, 200) + + # Same feed params → same signed token → get_or_create returns existing record + r2 = self.client.get("/api/feeds/share?reason=second") + self.assertEqual(r2.status_code, 200) + + # Both calls must have produced the exact same token/URL + self.assertEqual(r1.json()["url"], r2.json()["url"]) + + # Only one ShareToken should exist (not two) + token_hash = self._hash_from_url(r1.json()["url"]) + self.assertEqual(ShareToken.objects.filter(token_hash=token_hash).count(), 1) + + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(share_token.reason, "first") + + # ── helpers ──────────────────────────────────────────────────────────── + + @staticmethod + def _hash_from_url(url): + import hashlib + + raw_token = url.split("/api/feeds/consume/")[1] + return hashlib.sha256(raw_token.encode()).hexdigest() + + +class FeedsTokensListTestCase(CustomTestCase): + """Tests for GET /api/feeds/tokens/ — lists the calling user's share tokens.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + self.client.force_authenticate(user=self.superuser) + + def test_empty_list(self): + """A user with no tokens gets an empty JSON list.""" + response = self.client.get("/api/feeds/tokens/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_list_returns_own_tokens(self): + """After creating two tokens, the list endpoint returns both.""" + self.client.get("/api/feeds/share?reason=alpha") + self.client.get("/api/feeds/share?reason=beta&asn=11111") + response = self.client.get("/api/feeds/tokens/") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 2) + + reasons = {t["reason"] for t in data} + self.assertEqual(reasons, {"alpha", "beta"}) + + def test_list_returns_only_own_tokens(self): + """Tokens created by another user are NOT visible.""" + self.client.get("/api/feeds/share?reason=superuser-token") + + other_client = APIClient() + other_client.force_authenticate(user=self.regular_user) + other_client.get("/api/feeds/share?reason=regular-token&asn=22222") + + # superuser sees only their own + response = self.client.get("/api/feeds/tokens/") + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["reason"], "superuser-token") + + # regular_user sees only their own + response = other_client.get("/api/feeds/tokens/") + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["reason"], "regular-token") + + def test_hash_prefix_not_full_hash(self): + """The response exposes only the first 12 chars of the hash.""" + self.client.get("/api/feeds/share?reason=test") + response = self.client.get("/api/feeds/tokens/") + data = response.json() + self.assertEqual(len(data[0]["hash_prefix"]), 12) + + def test_token_metadata_fields(self): + """Each token entry contains the expected metadata keys.""" + self.client.get("/api/feeds/share?reason=check-fields") + response = self.client.get("/api/feeds/tokens/") + token = response.json()[0] + expected_keys = {"hash_prefix", "reason", "created_at", "revoked", "revoked_at"} + self.assertEqual(set(token.keys()), expected_keys) + self.assertFalse(token["revoked"]) + self.assertIsNone(token["revoked_at"]) + + def test_revoked_token_shows_status(self): + """After revocation, the token list reflects revoked=True.""" + share = self.client.get("/api/feeds/share?reason=to-revoke") + raw_token = share.json()["url"].split("/api/feeds/consume/")[1] + self.client.get(f"/api/feeds/revoke/{raw_token}") + + response = self.client.get("/api/feeds/tokens/") + token = response.json()[0] + self.assertTrue(token["revoked"]) + self.assertIsNotNone(token["revoked_at"]) + + def test_unauthenticated_returns_401_or_403(self): + """Unauthenticated requests are rejected.""" + anon = APIClient() + response = anon.get("/api/feeds/tokens/") + self.assertIn(response.status_code, [401, 403]) + + def test_list_ordering_newest_first(self): + """Tokens are returned newest-first (descending created_at).""" + self.client.get("/api/feeds/share?reason=first") + self.client.get("/api/feeds/share?reason=second&asn=22222") + response = self.client.get("/api/feeds/tokens/") + data = response.json() + self.assertEqual(data[0]["reason"], "second") + self.assertEqual(data[1]["reason"], "first") From a60c45222f8a01d03105f46b19c700c4b697b4fc Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:47:37 +0200 Subject: [PATCH 03/22] Fix bucket update logic. Closes #1246 (#1247) * convert hit to dict in _bucket_key_from_hit and use correct type hint * adapt tests --- greedybear/cronjobs/bucket_utils.py | 17 +++++------ tests/greedybear/cronjobs/test_trending.py | 33 +++++++++++----------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/greedybear/cronjobs/bucket_utils.py b/greedybear/cronjobs/bucket_utils.py index cdad51ddc..82548fa65 100644 --- a/greedybear/cronjobs/bucket_utils.py +++ b/greedybear/cronjobs/bucket_utils.py @@ -1,9 +1,10 @@ import logging from collections import Counter -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from datetime import datetime from ipaddress import ip_address -from typing import Any + +from elasticsearch.dsl.response import Hit from greedybear.cronjobs.extraction.utils import parse_timestamp from greedybear.cronjobs.repositories import TrendingBucketRepository @@ -11,7 +12,6 @@ logger = logging.getLogger(__name__) -BucketHit = Mapping[str, Any] BucketKey = tuple[str, str, datetime] @@ -20,10 +20,11 @@ def _bucket_start(timestamp: str) -> datetime: return parsed.replace(minute=0, second=0, microsecond=0) -def _bucket_key_from_hit(hit: BucketHit) -> BucketKey | None: - attacker_ip = hit.get("src_ip") - feed_type = hit.get("type") - timestamp = hit.get("@timestamp") +def _bucket_key_from_hit(hit: Hit) -> BucketKey | None: + hit_dict = hit.to_dict() + attacker_ip = hit_dict.get("src_ip") + feed_type = hit_dict.get("type") + timestamp = hit_dict.get("@timestamp") if not attacker_ip or not feed_type or not timestamp: return None @@ -42,7 +43,7 @@ def _bucket_key_from_hit(hit: BucketHit) -> BucketKey | None: return None -def update_activity_buckets_from_hits(hits: Iterable[BucketHit]) -> int: +def update_activity_buckets_from_hits(hits: Iterable[Hit]) -> int: counters: Counter[BucketKey] = Counter() for hit in hits: key = _bucket_key_from_hit(hit) diff --git a/tests/greedybear/cronjobs/test_trending.py b/tests/greedybear/cronjobs/test_trending.py index 7d97dc4c6..74b736e12 100644 --- a/tests/greedybear/cronjobs/test_trending.py +++ b/tests/greedybear/cronjobs/test_trending.py @@ -2,6 +2,7 @@ from unittest.mock import patch from django.test import SimpleTestCase, override_settings +from elasticsearch.dsl.response import Hit from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron from greedybear.cronjobs.bucket_utils import update_activity_buckets_from_hits @@ -71,9 +72,9 @@ def test_upsert_increments_existing_bucket_and_creates_missing_bucket(self): unique_keys = update_activity_buckets_from_hits( [ - {"src_ip": "1.1.1.1", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "1.1.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:50:00"}, - {"src_ip": "2.2.2.2", "type": "Heralding", "@timestamp": "2026-03-20T09:10:00"}, + Hit({"_source": {"src_ip": "1.1.1.1", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "1.1.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:50:00"}}), + Hit({"_source": {"src_ip": "2.2.2.2", "type": "Heralding", "@timestamp": "2026-03-20T09:10:00"}}), ] ) @@ -96,10 +97,10 @@ def test_upsert_increments_existing_bucket_and_creates_missing_bucket(self): def test_invalid_hits_are_ignored(self): unique_keys = update_activity_buckets_from_hits( [ - {"src_ip": "", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "999.999.999.999", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "3.3.3.3", "type": "", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "3.3.3.3", "type": "cowrie"}, + Hit({"_source": {"src_ip": "", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "999.999.999.999", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "3.3.3.3", "type": "", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "3.3.3.3", "type": "cowrie"}}), ] ) @@ -109,7 +110,7 @@ def test_invalid_hits_are_ignored(self): def test_invalid_timestamp_is_ignored(self): unique_keys = update_activity_buckets_from_hits( [ - {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "not-a-timestamp"}, + Hit({"_source": {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "not-a-timestamp"}}), ] ) self.assertEqual(unique_keys, 0) @@ -118,12 +119,12 @@ def test_invalid_timestamp_is_ignored(self): def test_non_global_ip_hits_are_ignored(self): unique_keys = update_activity_buckets_from_hits( [ - {"src_ip": "10.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "127.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "224.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "169.254.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "240.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, - {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + Hit({"_source": {"src_ip": "10.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "127.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "224.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "169.254.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "240.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + Hit({"_source": {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), ] ) @@ -134,7 +135,7 @@ def test_non_global_ip_hits_are_ignored(self): def test_global_ipv6_hit_is_counted(self): unique_keys = update_activity_buckets_from_hits( [ - {"src_ip": "2001:4860:4860::8888", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, + Hit({"_source": {"src_ip": "2001:4860:4860::8888", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}}), ] ) self.assertEqual(unique_keys, 1) @@ -166,7 +167,7 @@ def test_upsert_uses_multiple_batches_for_large_counter_sets(self): @patch("greedybear.cronjobs.bucket_utils.TrendingBucketRepository.upsert_bucket_counts", side_effect=Exception("db down")) def test_upsert_failure_returns_zero(self, mock_upsert): - unique_keys = update_activity_buckets_from_hits([{"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}]) + unique_keys = update_activity_buckets_from_hits([Hit({"_source": {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}})]) self.assertEqual(unique_keys, 0) mock_upsert.assert_called_once() From c0725413f6baaed29b321dee0db7e9c391f01dc0 Mon Sep 17 00:00:00 2001 From: Varun chauhan <115783538+chauhan-varun@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:32:35 +0530 Subject: [PATCH 04/22] Standardize country statistics using ISO codes. Closes #1181 (#1197) * feat: add ISO country code mapping utility and update statistics API to include country codes * fix: make ISO code lookup case-insensitive in getStandardMapName and update test expectations * feat: add Antarctica, N. Cyprus, Somaliland, and Kosovo to ISO mapping and tests * test: update test data structures to include country codes and expected interaction fields * refactor: reformat migration files for consistent style and readability * test: add expected_interactions parameter to mock data in tests/__init__.py * test: update country code filter test values to IT and FR * refactor: replace custom country name normalization with ISO-3166-1 alpha-2 numeric lookups for map rendering and store aggregation --- api/views/statistics.py | 13 +++- frontend/package-lock.json | 19 ++++++ frontend/package.json | 1 + .../components/dashboard/AttackOriginMap.jsx | 24 ++++--- .../src/stores/useAttackerCountriesStore.jsx | 48 ++++++++------ frontend/src/utils/country.js | 33 ---------- .../AttackOriginCountriesChart.test.jsx | 16 ++--- .../dashboard/AttackOriginMap.test.jsx | 31 ++++++---- .../stores/useAttackerCountriesStore.test.jsx | 62 ++++++++++++++++--- frontend/tests/utils/country.test.js | 25 -------- tests/__init__.py | 4 ++ tests/api/views/test_feeds_advanced_view.py | 6 +- tests/api/views/test_statistics_view.py | 6 ++ 13 files changed, 168 insertions(+), 120 deletions(-) delete mode 100644 frontend/src/utils/country.js delete mode 100644 frontend/tests/utils/country.test.js diff --git a/api/views/statistics.py b/api/views/statistics.py index d4bd1f751..bbcdb017a 100644 --- a/api/views/statistics.py +++ b/api/views/statistics.py @@ -86,18 +86,25 @@ def countries(self, request): request: The incoming request object. Returns: - Response: A JSON list of {country, count} objects ordered by count descending. + Response: A JSON list of {country, code, count} objects ordered by count descending. """ delta, _ = self.__parse_range(self.request) qs = ( IOC.objects.filter(last_seen__gte=delta) .exclude(attacker_country="") .filter(honeypots__active=True) - .values("attacker_country") + .values("attacker_country", "attacker_country_code") .annotate(count=Count("id", distinct=True)) .order_by("-count") ) - data = [{"country": item["attacker_country"], "count": item["count"]} for item in qs] + data = [ + { + "country": item["attacker_country"], + "code": item["attacker_country_code"], + "count": item["count"], + } + for item in qs + ] return Response(data) @action(detail=False, methods=["get"]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8156ffaa6..e27f96280 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "axios-hooks": "^3.1.5", "bootstrap": ">=5.3.8", "formik": "^2.4.9", + "i18n-iso-countries": "^7.14.0", "prop-types": "^15.8.1", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -3324,6 +3325,12 @@ "node": ">=8" } }, + "node_modules/diacritics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", + "integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4881,6 +4888,18 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/i18n-iso-countries": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", + "integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==", + "license": "MIT", + "dependencies": { + "diacritics": "1.3.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a512265c4..256a56559 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "axios-hooks": "^3.1.5", "bootstrap": ">=5.3.8", "formik": "^2.4.9", + "i18n-iso-countries": "^7.14.0", "prop-types": "^15.8.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/frontend/src/components/dashboard/AttackOriginMap.jsx b/frontend/src/components/dashboard/AttackOriginMap.jsx index a418c9eb8..803fa8c31 100644 --- a/frontend/src/components/dashboard/AttackOriginMap.jsx +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -6,7 +6,9 @@ import { ZoomableGroup, } from "react-simple-maps"; import { useTimePickerStore } from "@certego/certego-ui"; +import countries from "i18n-iso-countries"; import useAttackerCountriesStore from "../../stores/useAttackerCountriesStore"; + const WORLD_ATLAS_GEO_URL = `${import.meta.env.BASE_URL}countries-110m.json`; function lerpColor(a, b, t) { @@ -52,12 +54,18 @@ export default function AttackOriginMap() { fetchData(range); }, [range, fetchData]); + /** + * Resolve fill colour for a geography. + * geo.id is the ISO 3166-1 numeric code (e.g. "840"). + * i18n-iso-countries converts it to alpha-2 ("US"), which is the key + * used in countryData — no hand-maintained name table required. + */ const getColor = React.useCallback( - (geoName) => { - const count = countryData[geoName]; + (geoId) => { + const alpha2 = countries.numericToAlpha2(geoId); + const count = alpha2 ? countryData[alpha2] : undefined; if (maxCount <= 0 || !count) return COLOR_EMPTY; - const t = Math.sqrt(count / maxCount); // sqrt scale so small values are still visible - // 3-stop: low (yellow) → mid (orange) → high (red) + const t = Math.sqrt(count / maxCount); if (t < 0.5) return lerpColor(COLOR_LOW, COLOR_MID, t * 2); return lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); }, @@ -66,13 +74,13 @@ export default function AttackOriginMap() { const handleMouseEnter = React.useCallback( (geo, evt) => { - const name = geo.properties.name; - const count = countryData[name] ?? 0; + const alpha2 = countries.numericToAlpha2(geo.id); + const count = alpha2 ? (countryData[alpha2] ?? 0) : 0; setTooltip({ visible: true, x: evt.clientX, y: evt.clientY, - name, + name: geo.properties.name, // canonical TopoJSON name for display count, }); }, @@ -171,7 +179,7 @@ export default function AttackOriginMap() { handleMouseEnter(geo, evt)} diff --git a/frontend/src/stores/useAttackerCountriesStore.jsx b/frontend/src/stores/useAttackerCountriesStore.jsx index 2240c8397..602bca37a 100644 --- a/frontend/src/stores/useAttackerCountriesStore.jsx +++ b/frontend/src/stores/useAttackerCountriesStore.jsx @@ -1,7 +1,6 @@ import axios from "axios"; import { create } from "zustand"; import { IOC_ATTACKER_COUNTRIES_URI } from "../constants/api"; -import { normalizeCountryName } from "../utils/country"; const useAttackerCountriesStore = create((set, get) => ({ normalizedData: [], @@ -34,33 +33,42 @@ const useAttackerCountriesStore = create((set, get) => ({ signal: controller.signal, }); - const normalizedData = (Array.isArray(resp?.data) ? resp.data : []).map( - (item) => { - if ( - item && - typeof item === "object" && - typeof item.country === "string" - ) { - return { ...item, country: normalizeCountryName(item.country) }; - } - return item; - }, - ); - + const rawData = Array.isArray(resp?.data) ? resp.data : []; const countryDataMap = {}; + const countryNameMap = {}; // alpha-2 code → first-seen display name let maxCount = 0; - normalizedData.forEach((item) => { + rawData.forEach((item) => { if (item && typeof item === "object") { - const { country, count } = item; - if (typeof country === "string") { - const countNum = Number(count) || 0; - countryDataMap[country] = countNum; - if (countNum > maxCount) maxCount = countNum; + const code = + typeof item.code === "string" ? item.code.toUpperCase() : null; + if (!code) return; // skip items without an ISO-A2 code + + const countNum = Number(item.count) || 0; + + // Aggregate count by alpha-2 code + countryDataMap[code] = (countryDataMap[code] || 0) + countNum; + + // Keep the first-seen display name for this code + if (!countryNameMap[code]) { + countryNameMap[code] = item.country || code; + } + + if (countryDataMap[code] > maxCount) { + maxCount = countryDataMap[code]; } } }); + // Build unique aggregated list for charts + const normalizedData = Object.entries(countryDataMap) + .map(([code, count]) => ({ + country: countryNameMap[code], + count, + code, + })) + .sort((a, b) => b.count - a.count); + if (get().currentController === controller) { set({ normalizedData, diff --git a/frontend/src/utils/country.js b/frontend/src/utils/country.js deleted file mode 100644 index 0ef67ac40..000000000 --- a/frontend/src/utils/country.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Normalise country names from T-Pot geoip to match Natural Earth names used by world-atlas@2. - * (https://github.com/topojson/world-atlas) - */ -export const COUNTRY_NAME_FIXES = { - "United States": "United States of America", - "Czech Republic": "Czechia", - "Ivory Coast": "Côte d'Ivoire", - "Democratic Republic of the Congo": "Dem. Rep. Congo", - "Republic of the Congo": "Congo", - "Bosnia and Herzegovina": "Bosnia and Herz.", - "Central African Republic": "Central African Rep.", - "Dominican Republic": "Dominican Rep.", - "Equatorial Guinea": "Eq. Guinea", - "South Sudan": "S. Sudan", - "North Macedonia": "Macedonia", - Eswatini: "eSwatini", - "State of Palestine": "Palestine", - "Western Sahara": "W. Sahara", - "Solomon Islands": "Solomon Is.", - "Falkland Islands": "Falkland Is.", - "French Southern Territories": "Fr. S. Antarctic Lands", -}; - -/** - * Returns a normalised country name if a fix is available, otherwise returns the original name. - * - * @param {string|null|undefined} name - Raw country name (e.g., from T-Pot GeoIP) - * @returns {string|null|undefined} - Normalised country name (matching Natural Earth standards) - */ -export function normalizeCountryName(name) { - return COUNTRY_NAME_FIXES[name] ?? name; -} diff --git a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx index 03320e0d4..44fba10d1 100644 --- a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx @@ -29,23 +29,25 @@ vi.mock("recharts", async (importOriginal) => { }); const COUNTRIES_DATA = [ - { country: "China", count: 120 }, - { country: "United States", count: 80 }, - { country: "Russia", count: 60 }, - { country: "Germany", count: 40 }, - { country: "India", count: 30 }, + { country: "China", code: "CN", count: 120 }, + { country: "United States", code: "US", count: 80 }, + { country: "Russia", code: "RU", count: 60 }, + { country: "Germany", code: "DE", count: 40 }, + { country: "India", code: "IN", count: 30 }, ]; -// 16 entries (one more than the 15-entry limit) +// 16 entries (one more than the 15-entry limit). +// Each entry needs a code so the store doesn't skip codeless items. const SIXTEEN_COUNTRIES = Array.from({ length: 16 }, (_, i) => ({ country: `Country${i + 1}`, + code: `T${String(i + 1).padStart(1, "0")}`.slice(0, 2), // fictional alpha-2 count: 100 - i, })); describe("AttackOriginCountriesChart", () => { beforeEach(() => { useAttackerCountriesStore.setState({ - rawData: [], + normalizedData: [], countryDataMap: {}, maxCount: 0, loading: false, diff --git a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx index 493d90b29..6ec9ac38e 100644 --- a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx @@ -12,7 +12,10 @@ vi.mock("@certego/certego-ui", () => ({ useTimePickerStore: () => ({ range: "7d" }), })); -// Mock react-simple-maps components +// Mock react-simple-maps components. +// Each mock geography carries a numeric ISO id (geo.id) so that +// AttackOriginMap can resolve it to an alpha-2 code via i18n-iso-countries, +// matching the alpha-2-keyed countryDataMap from the store. vi.mock("react-simple-maps", () => ({ ComposableMap: ({ children, onMouseMove, onMouseLeave }) => (
({ Geographies: ({ children }) => children({ geographies: [ - { rsmKey: "geo-cn", properties: { name: "China" } }, - { rsmKey: "geo-usa", properties: { name: "United States of America" } }, - { rsmKey: "geo-fr", properties: { name: "France" } }, + { rsmKey: "geo-cn", id: "156", properties: { name: "China" } }, + { + rsmKey: "geo-usa", + id: "840", + properties: { name: "United States of America" }, + }, + { rsmKey: "geo-fr", id: "250", properties: { name: "France" } }, ], }), Geography: ({ fill, onMouseEnter, onMouseLeave, geography }) => ( @@ -43,15 +50,15 @@ vi.mock("react-simple-maps", () => ({ })); const COUNTRIES_DATA = [ - { country: "China", count: 120 }, - { country: "United States", count: 80 }, - { country: "Germany", count: 40 }, + { country: "China", code: "CN", count: 120 }, + { country: "United States", code: "US", count: 80 }, + { country: "Germany", code: "DE", count: 40 }, ]; describe("AttackOriginMap", () => { beforeEach(() => { useAttackerCountriesStore.setState({ - rawData: [], + normalizedData: [], countryDataMap: {}, maxCount: 0, loading: false, @@ -127,15 +134,17 @@ describe("AttackOriginMap", () => { await waitFor(() => expect(screen.getByTestId("geography-geo-fr")).toBeInTheDocument(), ); - // France is not in COUNTRIES_DATA so it must receive the empty/default colour + // France (id="250") is not in COUNTRIES_DATA so it must receive the empty/default colour const franceEl = screen.getByTestId("geography-geo-fr"); expect(franceEl.dataset.fill).toBe("#2a2a3a"); - // China IS in the data so it must not receive the empty colour + // China (id="156") IS in the data so it must not receive the empty colour const chinaEl = screen.getByTestId("geography-geo-cn"); expect(chinaEl.dataset.fill).not.toBe("#2a2a3a"); }); - test("country name normalisation is applied (United States => United States of America)", async () => { + test("geo.id numeric lookup correctly colours a country regardless of API name variant", async () => { + // The API returns "United States" but the map looks up by geo.id="840" → alpha-2 "US" + // so the geography is coloured even though the name doesn't match the TopoJSON name axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); render(); await waitFor(() => diff --git a/frontend/tests/stores/useAttackerCountriesStore.test.jsx b/frontend/tests/stores/useAttackerCountriesStore.test.jsx index e02932698..a040db5f3 100644 --- a/frontend/tests/stores/useAttackerCountriesStore.test.jsx +++ b/frontend/tests/stores/useAttackerCountriesStore.test.jsx @@ -45,8 +45,8 @@ describe("useAttackerCountriesStore", () => { const mockRange = "24h"; const rangeStr = JSON.stringify(mockRange); const mockData = [ - { country: "United States", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, ]; test("successfully fetches and normalizes data", async () => { @@ -55,13 +55,14 @@ describe("useAttackerCountriesStore", () => { await useAttackerCountriesStore.getState().fetchData(mockRange); const state = useAttackerCountriesStore.getState(); + // countryDataMap is keyed by alpha-2; normalizedData uses raw API name expect(state.normalizedData).toEqual([ - { country: "United States of America", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, ]); expect(state.countryDataMap).toEqual({ - "United States of America": 100, - Italy: 50, + US: 100, + IT: 50, }); expect(state.maxCount).toBe(100); expect(state.loading).toBe(false); @@ -144,8 +145,49 @@ describe("useAttackerCountriesStore", () => { expect(axios.get).toHaveBeenCalledTimes(2); expect(useAttackerCountriesStore.getState().normalizedData).toEqual([ - { country: "United States of America", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, + ]); + }); + + test("aggregates data correctly when same ISO code has different names", async () => { + const complexMockData = [ + { country: "United States", code: "US", count: 100 }, + { country: "USA", code: "US", count: 50 }, + { country: "Italy", code: "IT", count: 30 }, + ]; + axios.get.mockResolvedValue({ data: complexMockData }); + + await useAttackerCountriesStore.getState().fetchData("all"); + + const state = useAttackerCountriesStore.getState(); + // Aggregated by alpha-2; display name is the first-seen value + expect(state.normalizedData).toEqual([ + { country: "United States", code: "US", count: 150 }, + { country: "Italy", code: "IT", count: 30 }, + ]); + expect(state.countryDataMap).toEqual({ + US: 150, + IT: 30, + }); + expect(state.maxCount).toBe(150); + }); + + test("skips items that have no ISO code", async () => { + const mixedData = [ + { country: "United States", code: "US", count: 100 }, + { country: "Unknown", code: null, count: 50 }, + { country: "Italy", code: "IT", count: 30 }, + ]; + axios.get.mockResolvedValue({ data: mixedData }); + + await useAttackerCountriesStore.getState().fetchData(mockRange); + + const state = useAttackerCountriesStore.getState(); + // The entry with no code is excluded + expect(state.normalizedData).toEqual([ + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 30 }, ]); }); @@ -191,8 +233,8 @@ describe("useAttackerCountriesStore", () => { // Now loading should be false expect(useAttackerCountriesStore.getState().loading).toBe(false); expect(useAttackerCountriesStore.getState().normalizedData).toEqual([ - { country: "United States of America", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, ]); }); }); diff --git a/frontend/tests/utils/country.test.js b/frontend/tests/utils/country.test.js deleted file mode 100644 index 8457b8308..000000000 --- a/frontend/tests/utils/country.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { normalizeCountryName } from "../../src/utils/country"; - -describe("normalizeCountryName", () => { - it("should normalize known mismatched names", () => { - expect(normalizeCountryName("United States")).toBe( - "United States of America", - ); - expect(normalizeCountryName("Czech Republic")).toBe("Czechia"); - expect(normalizeCountryName("Ivory Coast")).toBe("Côte d'Ivoire"); - expect(normalizeCountryName("South Sudan")).toBe("S. Sudan"); - }); - - it("should return the same name if no mismatch is known", () => { - expect(normalizeCountryName("Italy")).toBe("Italy"); - expect(normalizeCountryName("Brazil")).toBe("Brazil"); - expect(normalizeCountryName("France")).toBe("France"); - }); - - it("should handle edge cases like empty strings or nulls", () => { - expect(normalizeCountryName("")).toBe(""); - expect(normalizeCountryName(null)).toBe(null); - expect(normalizeCountryName(undefined)).toBe(undefined); - }); -}); diff --git a/tests/__init__.py b/tests/__init__.py index 021340269..d8e3d58ef 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -53,6 +53,7 @@ def setUpTestData(cls): recurrence_probability=0.1, expected_interactions=11.1, attacker_country="China", + attacker_country_code="CN", ) cls.ioc_2 = IOC.objects.create( @@ -74,6 +75,7 @@ def setUpTestData(cls): recurrence_probability=0.1, expected_interactions=11.1, attacker_country="China", + attacker_country_code="CN", ) cls.ioc_3 = IOC.objects.create( @@ -95,6 +97,7 @@ def setUpTestData(cls): recurrence_probability=0.1, expected_interactions=11.1, attacker_country="United States", + attacker_country_code="US", ) cls.ioc_domain = IOC.objects.create( @@ -144,6 +147,7 @@ def setUpTestData(cls): attack_count=1, interaction_count=1, attacker_country="Russia", + attacker_country_code="RU", ) cls.ioc_inactive_country.honeypots.add(cls.ddospot) cls.ioc_inactive_country.save() diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index 2b70df106..7c10b68bf 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -236,12 +236,12 @@ def test_filter_by_date_range(self): def test_filter_by_country_code(self): """Filter by country_code returns only matching IOCs.""" - self.ioc.attacker_country_code = "CN" + self.ioc.attacker_country_code = "IT" self.ioc.save() - self.ioc2.attacker_country_code = "US" + self.ioc2.attacker_country_code = "FR" self.ioc2.save() - response = self.client.get("/api/feeds/advanced/?country_code=CN") + response = self.client.get("/api/feeds/advanced/?country_code=IT") self.assertEqual(response.status_code, 200) iocs = response.json()["iocs"] self.assertEqual(len(iocs), 1) diff --git a/tests/api/views/test_statistics_view.py b/tests/api/views/test_statistics_view.py index 59a7b4dfa..fb951559a 100644 --- a/tests/api/views/test_statistics_view.py +++ b/tests/api/views/test_statistics_view.py @@ -65,6 +65,12 @@ def test_200_countries(self): self.assertNotIn("Russia", countries) self.assertEqual(counts["China"], 2) self.assertEqual(counts["United States"], 1) + + # check codes + codes = {item["country"]: item["code"] for item in data} + self.assertEqual(codes["China"], "CN") + self.assertEqual(codes["United States"], "US") + # Results must be ordered descending by count count_values = [item["count"] for item in data] self.assertEqual(count_values, sorted(count_values, reverse=True)) From 2bfbbedc640e6683cc95539ee85eb5a94835abec Mon Sep 17 00:00:00 2001 From: Vipeen Kumar Date: Thu, 16 Apr 2026 19:07:51 +0000 Subject: [PATCH 05/22] =?UTF-8?q?Fix=20include=5Fsimilar=20returning=20inv?= =?UTF-8?q?alid=20sessions=20(duration=20<=3D=200)=20and=20ad=E2=80=A6=20(?= =?UTF-8?q?#1209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix include_similar returning invalid sessions (duration <= 0) and add regression test * Fix queryset union issue causing CI failure * Format files with Ruff * Keep only duration filter in related sessions --- api/views/cowrie_session.py | 2 +- tests/api/views/test_cowrie_session_view.py | 22 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index 7bc8be4c3..df8b38ea1 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -101,7 +101,7 @@ def cowrie_session_view(request): 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).prefetch_related("source", "commands", "credentials") + related_sessions = CowrieSession.objects.filter(commands__cluster__in=clusters, duration__gt=0).prefetch_related("source", "commands", "credentials") sessions = sessions.union(related_sessions) response_data = { diff --git a/tests/api/views/test_cowrie_session_view.py b/tests/api/views/test_cowrie_session_view.py index 5d08a6ad4..fa960d5bb 100644 --- a/tests/api/views/test_cowrie_session_view.py +++ b/tests/api/views/test_cowrie_session_view.py @@ -1,6 +1,7 @@ from django.test import override_settings from rest_framework.test import APIClient +from greedybear.models import CowrieSession from tests import CustomTestCase @@ -34,6 +35,27 @@ def test_ip_address_query_with_similar(self): self.assertNotIn("sessions", response.data) self.assertEqual(len(response.data["sources"]), 2) + def test_include_similar_excludes_non_positive_duration_sessions(self): + """Test that include_similar only returns sessions with duration > 0.""" + CowrieSession.objects.create( + session_id=int("dddddddddddd", 16), + start_time=self.current_time, + duration=0, + login_attempt=True, + command_execution=True, + interaction_count=1, + source=self.ioc_3, + commands=self.command_sequence_2, + ) + + response = self.client.get("/api/cowrie_session?query=140.246.171.141&include_similar=true&include_session_data=true") + + self.assertEqual(response.status_code, 200) + self.assertIn("99.99.99.99", response.data["sources"]) + self.assertNotIn("100.100.100.100", response.data["sources"]) + self.assertTrue(any(session["duration"] > 0 for session in response.data["sessions"])) + self.assertFalse(any(session["source"] == "100.100.100.100" for session in response.data["sessions"])) + def test_ip_address_query_with_credentials(self): """Test view with a valid IP address query including credentials.""" response = self.client.get("/api/cowrie_session?query=140.246.171.141&include_credentials=true") From c2203be1a23c45bc94f82eace5ad498ecb396739 Mon Sep 17 00:00:00 2001 From: Manik Date: Fri, 17 Apr 2026 00:46:33 +0530 Subject: [PATCH 06/22] feat: harden Django security settings for HTTPS deployments. Closes #1056 (#1244) * add security hardening settings to settings.py * add tests for security settings * test: verify cookie security matches environment * fix trailing whitespace in test file --- greedybear/settings.py | 21 +++++++++++++++ tests/greedybear/test_security_settings.py | 30 ++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/greedybear/test_security_settings.py diff --git a/greedybear/settings.py b/greedybear/settings.py index 64888b69d..eeeb26e64 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -59,6 +59,27 @@ CSRF_COOKIE_SAMESITE = "Strict" CSRF_COOKIE_HTTPONLY = True +# Prevent browsers from MIME-sniffing the content type, reducing +# the risk of drive-by downloads. +SECURE_CONTENT_TYPE_NOSNIFF = True + +# Block framing of the site entirely to prevent clickjacking. +# XFrameOptionsMiddleware is already in the middleware stack but +# defaults to SAMEORIGIN; DENY is stricter and appropriate here +# because GreedyBear has no legitimate need to be framed. +X_FRAME_OPTIONS = "DENY" + +if STAGE_PRODUCTION: + # Mark session and CSRF cookies as Secure so they are only + # sent over HTTPS connections. Gated to production because + # local/CI environments run plain HTTP. + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + # NOTE: SECURE_SSL_REDIRECT is intentionally omitted. TLS is + # terminated at nginx, which already redirects HTTP -> HTTPS. + # Enabling it here would cause an infinite redirect loop because + # Django only sees plain HTTP from the gunicorn unix socket. + # Read DJANGO_ALLOWED_HOSTS from env (comma-separated). # Falls back to ["*"] for backward compatibility. # For security checks, see greedybear/checks.py. diff --git a/tests/greedybear/test_security_settings.py b/tests/greedybear/test_security_settings.py new file mode 100644 index 000000000..96bb53802 --- /dev/null +++ b/tests/greedybear/test_security_settings.py @@ -0,0 +1,30 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +from django.conf import settings +from django.test import SimpleTestCase + + +class SecuritySettingsTests(SimpleTestCase): + def test_content_type_nosniff_enabled(self): + """SECURE_CONTENT_TYPE_NOSNIFF should always be True.""" + self.assertTrue(settings.SECURE_CONTENT_TYPE_NOSNIFF) + + def test_x_frame_options_deny(self): + """X_FRAME_OPTIONS should be DENY to block all framing.""" + self.assertEqual(settings.X_FRAME_OPTIONS, "DENY") + + def test_ssl_redirect_not_set(self): + """SECURE_SSL_REDIRECT must not be enabled (TLS terminates at nginx).""" + self.assertFalse(getattr(settings, "SECURE_SSL_REDIRECT", False)) + + def test_cookie_security_matches_environment(self): + """Cookies should only be marked secure in production.""" + is_production = getattr(settings, "STAGE_PRODUCTION", False) + + # Test SESSION_COOKIE_SECURE + session_secure = getattr(settings, "SESSION_COOKIE_SECURE", False) + self.assertEqual(session_secure, is_production) + + # Test CSRF_COOKIE_SECURE + csrf_secure = getattr(settings, "CSRF_COOKIE_SECURE", False) + self.assertEqual(csrf_secure, is_production) From 4bb7a0cf034adfb983a93b921de382fe948273e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:20:24 +0200 Subject: [PATCH 07/22] build(deps): bump axios from 1.15.0 to 1.15.2 in /frontend (#1265) Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 56 +++----------------------------------- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 53 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e27f96280..f81d4c48d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@certego/certego-ui": "0.1.14", - "axios": "^1.15.0", + "axios": "^1.15.2", "axios-hooks": "^3.1.5", "bootstrap": ">=5.3.8", "formik": "^2.4.9", @@ -1112,9 +1112,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1135,9 +1132,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1158,9 +1152,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1181,9 +1172,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1204,9 +1192,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1227,9 +1212,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1415,9 +1397,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1435,9 +1414,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1455,9 +1431,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1475,9 +1448,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1495,9 +1465,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1515,9 +1482,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2449,9 +2413,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -5915,9 +5879,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5939,9 +5900,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5963,9 +5921,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5987,9 +5942,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index 256a56559..9ab55fcbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@certego/certego-ui": "0.1.14", - "axios": "^1.15.0", + "axios": "^1.15.2", "axios-hooks": "^3.1.5", "bootstrap": ">=5.3.8", "formik": "^2.4.9", From 765d215b5de5ab7831f9b29c588ba180559989f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:20:37 +0200 Subject: [PATCH 08/22] build(deps-dev): bump ruff from 0.15.10 to 0.15.11 (#1267) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.10 to 0.15.11. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.10...0.15.11) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.11 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 29713ba01..49a83e7d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dev = [ "django-watchfiles==1.4.0", ] lint = [ - "ruff==0.15.10", + "ruff==0.15.11", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 613af8979..c3868dbcc 100644 --- a/uv.lock +++ b/uv.lock @@ -546,7 +546,7 @@ dev = [ { name = "django-test-migrations", specifier = "==1.5.0" }, { name = "django-watchfiles", specifier = "==1.4.0" }, ] -lint = [{ name = "ruff", specifier = "==0.15.10" }] +lint = [{ name = "ruff", specifier = "==0.15.11" }] [[package]] name = "gunicorn" @@ -881,27 +881,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] From d76a174c03dc90c8eab9b810632a3e60f88f6739 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:21:10 +0200 Subject: [PATCH 09/22] build(deps): bump datasketch from 1.9.0 to 1.10.0 (#1268) Bumps [datasketch](https://github.com/ekzhu/datasketch) from 1.9.0 to 1.10.0. - [Release notes](https://github.com/ekzhu/datasketch/releases) - [Commits](https://github.com/ekzhu/datasketch/compare/v1.9.0...v1.10.0) --- updated-dependencies: - dependency-name: datasketch dependency-version: 1.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 49a83e7d9..fa72d64b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "pandas==3.0.2", "numpy==2.4.4", "joblib==1.5.3", - "datasketch==1.9.0", + "datasketch==1.10.0", # File Format Support "feedparser==6.0.12", "stix2==3.0.2", diff --git a/uv.lock b/uv.lock index c3868dbcc..6565df9e7 100644 --- a/uv.lock +++ b/uv.lock @@ -194,15 +194,15 @@ wheels = [ [[package]] name = "datasketch" -version = "1.9.0" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/d1/0b64dbc7626be277413daff298b9f026911389e75562952d5b7c662bbea1/datasketch-1.9.0.tar.gz", hash = "sha256:78d4560e415b0de11f595165887a0e4c9983d07b8f15dce9c53289a86bc12e92", size = 89790, upload-time = "2026-01-18T22:46:46.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699, upload-time = "2026-04-17T23:06:56.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/c8/ef06f4c9a0d7697c14c39475eaccd7d3774c34ecc693691079a21bd0d1f1/datasketch-1.9.0-py3-none-any.whl", hash = "sha256:48c18ae889862793609971c6d6d392d71c86793e838dbddd8aa53af6b1716e8a", size = 96542, upload-time = "2026-01-18T22:46:44.368Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914, upload-time = "2026-04-17T23:06:54.39Z" }, ] [[package]] @@ -521,7 +521,7 @@ lint = [ requires-dist = [ { name = "certego-saas", specifier = "==0.7.12" }, { name = "croniter", specifier = "==6.2.2" }, - { name = "datasketch", specifier = "==1.9.0" }, + { name = "datasketch", specifier = "==1.10.0" }, { name = "django", specifier = "==5.2.13" }, { name = "django-q2", specifier = "==1.9.0" }, { name = "django-rest-email-auth", specifier = "==5.0.0" }, From 81511bef5e5077dd6ef79e304ffe2c87f2a6d234 Mon Sep 17 00:00:00 2001 From: Rahul Guwani Date: Wed, 22 Apr 2026 12:51:35 +0530 Subject: [PATCH 10/22] feat: expose IoC-Sensor relationship in authenticated API responses. Closes #781 (#1188) * feat: expose IoC-Sensor relationship in authenticated API responses. Closes #781 * apply ruff formatting and fix inline import * fix: move Sensor import to top of test_enrichment_view.py --------- Co-authored-by: rahul-software-dev <24f3003169@ds.study.iitm.ac.in> --- api/serializers.py | 1 + api/views/feeds.py | 5 ++-- api/views/utils.py | 26 +++++++++++++++--- tests/api/views/test_enrichment_view.py | 14 ++++++++++ tests/api/views/test_feeds_advanced_view.py | 30 ++++++++++++++++++--- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index ead3b02dc..5485e323f 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -237,6 +237,7 @@ class FeedsResponseSerializer(serializers.Serializer): 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}'") diff --git a/api/views/feeds.py b/api/views/feeds.py index 11fbe1127..e31dff140 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -153,13 +153,14 @@ def feeds_advanced(request): valid_feed_types, tag_key=request.query_params.get("tag_key", "").strip(), tag_value=request.query_params.get("tag_value", "").strip(), + include_sensors=True, ) 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) + 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) + return feeds_response(request, iocs_queryset, feed_params, valid_feed_types, verbose=verbose, include_sensors=True) @api_view(["GET"]) diff --git a/api/views/utils.py b/api/views/utils.py index b281cc1bd..6e826b9ac 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -153,7 +153,9 @@ def get_valid_feed_types() -> frozenset[str]: return frozenset(feed_types) -def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, serializer_class=FeedsRequestSerializer, tag_key="", tag_value=""): +def get_queryset( + request, feed_params, valid_feed_types, is_aggregated=False, serializer_class=FeedsRequestSerializer, tag_key="", tag_value="", include_sensors=False +): """ Build a queryset to filter IOC data based on the request parameters. @@ -172,6 +174,8 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se - 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. Returns: QuerySet: The filtered queryset of IOC data. @@ -252,6 +256,15 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se 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)] @@ -276,7 +289,7 @@ 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): +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). @@ -339,13 +352,19 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N 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 + # 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") 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 + required_fields = tuple(("tags_json" if f == "tags" else f) for f in required_fields if f != "tags" or has_tags_annotation) + if has_sensors_annotation: + required_fields = required_fields + ("sensors_json",) iocs_iter: object if isinstance(iocs, list): @@ -362,6 +381,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N "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: diff --git a/tests/api/views/test_enrichment_view.py b/tests/api/views/test_enrichment_view.py index e7ad06f4d..21772f8e0 100644 --- a/tests/api/views/test_enrichment_view.py +++ b/tests/api/views/test_enrichment_view.py @@ -1,5 +1,6 @@ from rest_framework.test import APIClient +from greedybear.models import Sensor from tests import CustomTestCase @@ -86,3 +87,16 @@ def test_valid_domain(self): response = self.client.get("/api/enrichment?query=example.com") self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["found"], False) + + def test_enrichment_includes_sensors(self): + """Sensors field appears in enrichment response for authenticated users.""" + + sensor = Sensor.objects.create(address="10.0.0.3", label="enrichment-sensor") + self.ioc.sensors.add(sensor) + response = self.client.get(f"/api/enrichment?query={self.ioc.name}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["found"], True) + sensors = response.json()["ioc"]["sensors"] + self.assertEqual(len(sensors), 1) + self.assertEqual(sensors[0]["address"], "10.0.0.3") + self.assertEqual(sensors[0]["label"], "enrichment-sensor") diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index 7c10b68bf..daa303056 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -8,7 +8,7 @@ from rest_framework.test import APIClient from api.throttles import SharedFeedRateThrottle -from greedybear.models import IOC, AutonomousSystem, IocType, ShareToken +from greedybear.models import IOC, AutonomousSystem, IocType, Sensor, ShareToken from tests import CustomTestCase @@ -109,15 +109,37 @@ def test_200_feed_contains_attacker_country_code(self): """ self.ioc.attacker_country_code = "NP" self.ioc.save() - response = self.client.get("/api/feeds/advanced/") - iocs = response.json()["iocs"] target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None) - self.assertIsNotNone(target_ioc) self.assertEqual(target_ioc["attacker_country_code"], "NP") + def test_feeds_advanced_includes_sensors(self): + """Sensors field appears in feeds_advanced response for authenticated users.""" + sensor = Sensor.objects.create(address="10.0.0.1", label="test-sensor") + self.ioc.sensors.add(sensor) + response = self.client.get("/api/feeds/advanced/") + self.assertEqual(response.status_code, 200) + iocs = response.json()["iocs"] + target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None) + self.assertIsNotNone(target_ioc) + self.assertIn("sensors", target_ioc) + self.assertEqual(len(target_ioc["sensors"]), 1) + self.assertEqual(target_ioc["sensors"][0]["address"], "10.0.0.1") + self.assertEqual(target_ioc["sensors"][0]["label"], "test-sensor") + + def test_public_feeds_excludes_sensors(self): + """Sensors field must NOT appear in public feeds response.""" + sensor = Sensor.objects.create(address="10.0.0.2", label="secret-sensor") + self.ioc.sensors.add(sensor) + self.client.logout() + response = self.client.get("/api/feeds/cowrie/all/recent.json") + self.assertEqual(response.status_code, 200) + iocs = response.json()["iocs"] + for ioc in iocs: + self.assertNotIn("sensors", ioc) + class FeedsEnhancementsTestCase(CustomTestCase): """Tests for advanced filtering, STIX export, and shareable feeds functionality.""" From d63ebd0b8c26949473f53028ce6d0d11b50c5198 Mon Sep 17 00:00:00 2001 From: Varun chauhan <115783538+chauhan-varun@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:54:26 +0530 Subject: [PATCH 11/22] fix: Optimize Map Rendering Performance. Closes #1249 (#1259) * refactor: extract MapPaths component and memoize country color calculations in AttackOriginMap * refactor: add displayName and fix indentation formatting in MapPaths component --- .../components/dashboard/AttackOriginMap.jsx | 83 ++++++++++++------- 1 file changed, 53 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/dashboard/AttackOriginMap.jsx b/frontend/src/components/dashboard/AttackOriginMap.jsx index 803fa8c31..da0d9b3d8 100644 --- a/frontend/src/components/dashboard/AttackOriginMap.jsx +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -31,6 +31,38 @@ const COLOR_LOW = "#ffffb2"; const COLOR_MID = "#fd8d3c"; const COLOR_HIGH = "#bd0026"; +const MapPaths = React.memo( + ({ getColor, handleMouseEnter, handleMouseLeave }) => { + return ( + + {({ geographies }) => + geographies.map((geo) => ( + handleMouseEnter(geo, evt)} + onMouseLeave={handleMouseLeave} + style={{ + default: { outline: "none" }, + hover: { + outline: "none", + fill: "#facc15", + transition: "fill 80ms", + }, + pressed: { outline: "none" }, + }} + /> + )) + } + + ); + }, +); +MapPaths.displayName = "MapPaths"; + export default function AttackOriginMap() { const { range } = useTimePickerStore(); const { @@ -54,6 +86,20 @@ export default function AttackOriginMap() { fetchData(range); }, [range, fetchData]); + const colors = React.useMemo(() => { + const colorMap = {}; + if (maxCount <= 0) return colorMap; + for (const [alpha2, count] of Object.entries(countryData)) { + if (!count) continue; + const t = Math.sqrt(count / maxCount); + colorMap[alpha2] = + t < 0.5 + ? lerpColor(COLOR_LOW, COLOR_MID, t * 2) + : lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); + } + return colorMap; + }, [countryData, maxCount]); + /** * Resolve fill colour for a geography. * geo.id is the ISO 3166-1 numeric code (e.g. "840"). @@ -63,13 +109,9 @@ export default function AttackOriginMap() { const getColor = React.useCallback( (geoId) => { const alpha2 = countries.numericToAlpha2(geoId); - const count = alpha2 ? countryData[alpha2] : undefined; - if (maxCount <= 0 || !count) return COLOR_EMPTY; - const t = Math.sqrt(count / maxCount); - if (t < 0.5) return lerpColor(COLOR_LOW, COLOR_MID, t * 2); - return lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); + return (alpha2 && colors[alpha2]) || COLOR_EMPTY; }, - [countryData, maxCount], + [colors], ); const handleMouseEnter = React.useCallback( @@ -173,30 +215,11 @@ export default function AttackOriginMap() { onMouseLeave={handleMouseLeave} > - - {({ geographies }) => - geographies.map((geo) => ( - handleMouseEnter(geo, evt)} - onMouseLeave={handleMouseLeave} - style={{ - default: { outline: "none" }, - hover: { - outline: "none", - fill: "#facc15", - transition: "fill 80ms", - }, - pressed: { outline: "none" }, - }} - /> - )) - } - +
From 19b1c89a575810e17cb8d74a6c438741e1419fdb Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:29:33 +0200 Subject: [PATCH 12/22] Bump 3.4.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fa72d64b9..66a490429 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "greedybear" -version = "3.3.2" +version = "3.4.0" requires-python = "==3.13.*" dependencies = [ # Django core diff --git a/uv.lock b/uv.lock index 6565df9e7..954dd08c5 100644 --- a/uv.lock +++ b/uv.lock @@ -483,7 +483,7 @@ wheels = [ [[package]] name = "greedybear" -version = "3.3.2" +version = "3.4.0" source = { virtual = "." } dependencies = [ { name = "certego-saas" }, From 87f9fb197c4b402e39ce3b2adf15a64168926588 Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:10:32 +0200 Subject: [PATCH 13/22] Fix country code extraction. Closes #1272 (#1273) * Use correct filed for country code extraction * Adapt tests --- greedybear/cronjobs/extraction/utils.py | 2 +- tests/test_extraction_utils.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index ed96cc696..bdd002d3c 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -161,7 +161,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: geoip = next((h.get("geoip") for h in hits if h.get("geoip")), {}) attacker_country = geoip.get("country_name", "") - raw_country_code = geoip.get("country_iso_code", "") + raw_country_code = geoip.get("country_code2", "") attacker_country_code = raw_country_code if len(raw_country_code) == 2 else "" asn = geoip.get("asn") diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index ca5030913..2e1fa1c6d 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -693,7 +693,7 @@ def test_ioc_attacker_country_set_correctly(self): self.assertEqual(ioc.interaction_count, 1) def test_ioc_attacker_country_code_set_correctly(self): - """Verify that iocs_from_hits extracts country_iso_code from geoip.""" + """Verify that iocs_from_hits extracts country_code2 from geoip.""" hits = [ self._create_hit( src_ip="8.8.8.8", @@ -702,7 +702,7 @@ def test_ioc_attacker_country_code_set_correctly(self): ) ] - hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NP"} + hits[0]["geoip"] = {"country_name": "Nepal", "country_code2": "NP"} iocs = iocs_from_hits(hits) self.assertEqual(len(iocs), 1) @@ -712,7 +712,7 @@ def test_ioc_attacker_country_code_set_correctly(self): self.assertEqual(ioc.attacker_country_code, "NP") def test_ioc_attacker_country_code_defaults_to_empty(self): - """Verify that attacker_country_code defaults to empty when geoip has no country_iso_code.""" + """Verify that attacker_country_code defaults to empty when geoip has no country_code2.""" hits = [ self._create_hit( src_ip="8.8.8.8", @@ -740,7 +740,7 @@ def test_ioc_attacker_country_code_rejects_invalid_length(self): ) ] - hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NPL"} + hits[0]["geoip"] = {"country_name": "Nepal", "country_code2": "NPL"} iocs = iocs_from_hits(hits) self.assertEqual(len(iocs), 1) From 2522b76e0eee4ec5b49d5405daa78ba7ec31ae4d Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:15:20 +0200 Subject: [PATCH 14/22] Update URLs and README with new GreedyBear-Project references (#1269) * Update Contributing.md * Remove funding.yml * Update feed license URL * Remove IntelOwl links from footer * Update frontend tests * Update RSS feed source * Remove filter from RSS retrieval * Update README * Update shields in README * Add GSoC logo * Remove test case for filtering blog posts * Replace URLs in README * Add license paragraph * Improve language * Add contributor list * Fix format * Revert "Remove funding.yml" This reverts commit 78af28c94235f3c883c70c94b00918ea8ffed861. --- .github/CONTRIBUTING.md | 4 +- README.md | 71 +++++++----------- api/views/utils.py | 2 +- frontend/src/constants/index.js | 2 +- frontend/src/layouts/AppFooter.jsx | 22 +----- .../tests/components/feeds/Feeds.test.jsx | 2 +- greedybear/consts.py | 2 +- static/gsoc_logo.png | Bin 0 -> 83945 bytes tests/api/views/test_news_view.py | 32 -------- 9 files changed, 35 insertions(+), 102 deletions(-) create mode 100644 static/gsoc_logo.png diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7e63182b3..f5695ad25 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,3 +1 @@ -GreedyBear is handled by the same maintainers of [IntelOwl](https://github.com/intelowlproject/IntelOwl/). - -So, please refer to the [Contribute guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Contribute) \ No newline at end of file +Please refer to our [Contribution guidelines](https://github.com/GreedyBear-Project/GreedyBear/wiki/Contribute). diff --git a/README.md b/README.md index 6269d1b8c..a7ff51d44 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,25 @@

GreedyBear

# GreedyBear -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/intelowlproject/Greedybear)](https://github.com/intelowlproject/Greedybear/releases) -[![GitHub Repo stars](https://img.shields.io/github/stars/intelowlproject/Greedybear?style=social)](https://github.com/intelowlproject/Greedybear/stargazers) -[![Twitter Follow](https://img.shields.io/twitter/follow/intel_owl?style=social)](https://twitter.com/intel_owl) -[![Linkedin](https://img.shields.io/badge/LinkedIn-0077B5?style=flat&logo=linkedin&logoColor=white)](https://www.linkedin.com/company/intelowl/) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/GreedyBear-Project/Greedybear)](https://github.com/GreedyBear-Project/Greedybear/releases) +[![GitHub Repo stars](https://img.shields.io/github/stars/GreedyBear-Project/Greedybear?style=social)](https://github.com/GreedyBear-Project/Greedybear/stargazers) +![GitHub License](https://img.shields.io/github/license/GreedyBear-Project/GreedyBear) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![CodeQL](https://github.com/intelowlproject/GreedyBear/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/intelowlproject/GreedyBear/actions/workflows/codeql-analysis.yml) -[![Dependency Review](https://github.com/intelowlproject/GreedyBear/actions/workflows/dependency_review.yml/badge.svg)](https://github.com/intelowlproject/GreedyBear/actions/workflows/dependency_review.yml) -[![Pull request automation](https://github.com/intelowlproject/GreedyBear/actions/workflows/pull_request_automation.yml/badge.svg)](https://github.com/intelowlproject/GreedyBear/actions/workflows/pull_request_automation.yml) +[![CodeQL](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/codeql-analysis.yml) +[![Dependency Review](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/dependency_review.yml/badge.svg)](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/dependency_review.yml) +[![Pull request automation](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/pull_request_automation.yml/badge.svg)](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/pull_request_automation.yml) -The project goal is to extract data of the attacks detected by a [TPOT](https://github.com/telekom-security/tpotce) or a cluster of them and to generate some feeds that can be used to prevent and detect attacks. +The project goal is to extract attack data detected by a [T-Pot](https://github.com/telekom-security/tpotce) or a cluster of them and to generate some feeds that can be used to prevent and detect attacks. You can read the [official announcement here](https://www.honeynet.org/2021/12/27/new-project-available-greedybear/). -[Official announcement here](https://www.honeynet.org/2021/12/27/new-project-available-greedybear/). - -## Documentation - -Documentation about GreedyBear installation, usage, configuration and contribution can be found at [this link](https://github.com/GreedyBear-Project/GreedyBear/wiki) - -## Public feeds - -There are public feeds provided by [The Honeynet Project](https://www.honeynet.org) in this [site](https://greedybear.honeynet.org). [Example](https://greedybear.honeynet.org/api/feeds/cowrie/all/recent.txt) - -Please do not perform too many requests to extract feeds or you will be banned. - -If you want to be updated regularly, please download the feeds only once every 10 minutes (this is the time between each internal update). - -To check all the available feeds, Please refer to our [usage guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Usage) - - -## Enrichment Service - -GreedyBear provides an easy-to-query API to get the information available in GB regarding the queried observable (domain or IP address). - -To understand more, Please refer to our [usage guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Usage) - -## Run Greedybear on your environment -The tool has been created not only to provide the feeds from The Honeynet Project's cluster of TPOTs. - -If you manage one or more T-POTs of your own, you can get the code of this application and run Greedybear on your environment. -In this way, you are able to provide new feeds of your own. - -To install it locally, Please refer to our [installation guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Installation) +## How to ... +- **... try it out**: visit the [public instance](https://greedybear.honeynet.org) provided by [The Honeynet Project](https://www.honeynet.org) and take a look at a [threat intelligence live feed example](https://greedybear.honeynet.org/api/feeds/cowrie/all/recent.txt) +- **... dive in**: read through our documentation in the [Wiki](https://github.com/GreedyBear-Project/GreedyBear/wiki) and explore GreedyBear's features +- **... run your own instance**: to leverage everything GreedyBear has to offer, you might want to [install](https://github.com/GreedyBear-Project/GreedyBear/wiki/Installation) it and connect it to your own T-Pot +- **... stay up to date**: [read](https://greedybear-project.github.io/) and [subscribe](https://greedybear-project.github.io/feed.xml) to our blog, where we regularly write about the most recent changes and new features +- **... contact us**: using a Github [issue](https://github.com/GreedyBear-Project/GreedyBear/issues) or start a [discussion](https://github.com/GreedyBear-Project/GreedyBear/discussions) +- **... contribute**: read through our [contribution guidelines](https://github.com/GreedyBear-Project/GreedyBear/wiki/Contribute), open an [issue](https://github.com/GreedyBear-Project/GreedyBear/issues), get assigned and raise a [pull request](https://github.com/GreedyBear-Project/GreedyBear/pulls) ## Sponsors and Acknowledgements @@ -57,15 +34,23 @@ Thanks to [The Honeynet Project](https://www.honeynet.org) we are providing free #### Google Summer of Code GSoC logo -In 2026 we started participating to the [Google Summer of Code](https://summerofcode.withgoogle.com/) (GSoC)! +In 2026 we started participating in the [Google Summer of Code](https://summerofcode.withgoogle.com/) (GSoC)! If you are interested in participating in the next Google Summer of Code, check all the info available in the [dedicated repository](https://github.com/intelowlproject/gsoc)! -## Maintainers and Key Contributors +## Maintainers and Contributors This project was started as a personal Christmas project by [Matteo Lodi](https://twitter.com/matte_lodi) in 2021. Special thanks to: -* [Tim Leonhard](https://github.com/regulartim) for having greatly improved the project and added Machine Learning Models during his master thesis. He's the actual Principal Mantainer. -* [Martina Carella](https://github.com/carellamartina) for having created the GUI during her master thesis. -* [Daniele Rosetti](https://github.com/drosetti) for helping maintaining the Frontend. +- [Tim Leonhard](https://github.com/regulartim) for having greatly improved the project and added Machine Learning Models during his master thesis. He's the current Principal Maintainer. +- [Martina Carella](https://github.com/carellamartina) for having created the GUI during her master thesis. +- [Daniele Rosetti](https://github.com/drosetti) for helping maintaining the Frontend. +- and everyone who has contributed to GreedyBear! + + + GreedyBear contributors + + +## License +Distributed under the MIT license. See [`LICENSE`](LICENSE) for the full text. diff --git a/api/views/utils.py b/api/views/utils.py index 6e826b9ac..b4e64d586 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -577,7 +577,7 @@ def get_greedybear_news() -> list[dict]: feed = feedparser.parse(response.content) filtered_entries = sorted( - [entry for entry in feed.entries if "greedybear" in entry.get("title", "").lower() and entry.get("published_parsed")], + [entry for entry in feed.entries if entry.get("published_parsed")], key=lambda e: e.published_parsed, reverse=True, ) diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index ec73582ee..8279ca38a 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -23,4 +23,4 @@ export const UUID_REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; export const FEEDS_LICENSE = - "https://github.com/intelowlproject/GreedyBear/blob/main/FEEDS_LICENSE.md"; + "https://github.com/GreedyBear-Project/GreedyBear/blob/main/FEEDS_LICENSE.md"; diff --git a/frontend/src/layouts/AppFooter.jsx b/frontend/src/layouts/AppFooter.jsx index 67bd06c9f..053eb7f60 100644 --- a/frontend/src/layouts/AppFooter.jsx +++ b/frontend/src/layouts/AppFooter.jsx @@ -32,32 +32,14 @@ function AppFooter() { Follow us on: - - - - - - diff --git a/frontend/tests/components/feeds/Feeds.test.jsx b/frontend/tests/components/feeds/Feeds.test.jsx index c53d6b176..0dd86a863 100644 --- a/frontend/tests/components/feeds/Feeds.test.jsx +++ b/frontend/tests/components/feeds/Feeds.test.jsx @@ -113,7 +113,7 @@ describe("Feeds component", () => { }); expect(buttonFeedsLicense).toHaveAttribute( "href", - "https://github.com/intelowlproject/GreedyBear/blob/main/FEEDS_LICENSE.md", + "https://github.com/GreedyBear-Project/GreedyBear/blob/main/FEEDS_LICENSE.md", ); const feedTypeSelectElement = screen.getByLabelText("Feed type:"); diff --git a/greedybear/consts.py b/greedybear/consts.py index 5ed7f0d5a..77093051e 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -53,7 +53,7 @@ # we used this const to implement news feature -RSS_FEED_URL = "https://intelowlproject.github.io/feed.xml" +RSS_FEED_URL = "https://greedybear-project.github.io/feed.xml" CACHE_KEY_GREEDYBEAR_NEWS = "greedybear_news" CACHE_TIMEOUT_SECONDS = 60 * 60 diff --git a/static/gsoc_logo.png b/static/gsoc_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9c5ca29fbfff683d1aefb09234da3e01a75143fb GIT binary patch literal 83945 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 G70&_nd3DJE literal 0 HcmV?d00001 diff --git a/tests/api/views/test_news_view.py b/tests/api/views/test_news_view.py index 3cb78ca19..725fb9845 100644 --- a/tests/api/views/test_news_view.py +++ b/tests/api/views/test_news_view.py @@ -32,38 +32,6 @@ def test_returns_cached_data(self, mock_parse): self.assertEqual(result, cached_data) mock_parse.assert_not_called() - @patch("api.views.utils.feedparser.parse") - def test_filters_only_greedybear_posts(self, mock_parse): - mock_parse.return_value = FeedParserDict( - entries=[ - FeedParserDict( - title="IntelOwl Update", - summary="intelowl news", - published="Wed, 01 Jan 2026 00:00:00 GMT", - published_parsed=(2026, 1, 1, 0, 0, 0, 2, 1, 0), - link="https://example.com/1", - ), - FeedParserDict( - title="GreedyBear v3 Release", - summary="greedybear release notes", - published="Thu, 29 Jan 2026 00:00:00 GMT", - published_parsed=(2026, 1, 29, 0, 0, 0, 3, 29, 0), - link="https://example.com/2", - ), - FeedParserDict( - title="IntelOwl Improvements", - summary="Not related to GreedyBear", - published="Mon, 01 Sep 2025 00:00:00 GMT", - published_parsed=(2025, 9, 1, 0, 0, 0, 0, 244, 0), - link="https://example.com/3", - ), - ] - ) - - result = get_greedybear_news() - self.assertEqual(len(result), 1) - self.assertEqual(result[0]["title"], "GreedyBear v3 Release") - @patch("api.views.utils.feedparser.parse") def test_sorts_posts_by_date_desc(self, mock_parse): mock_parse.return_value = FeedParserDict( From d6b3165598a2a9d4abfa0aeff1fe2d6013cfaa3c Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:52:56 +0200 Subject: [PATCH 15/22] Add watchfiles command to qluster container for hot reloading in dev mode --- docker/local.override.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/local.override.yml b/docker/local.override.yml index c7a76921c..a006f8508 100644 --- a/docker/local.override.yml +++ b/docker/local.override.yml @@ -26,5 +26,6 @@ services: image: intelowlproject/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" environment: - DEBUG=True \ No newline at end of file From 2ea082f35c4c34fa6f76c50672853f031c79b2df Mon Sep 17 00:00:00 2001 From: Arnav Vinod Deshpande Date: Wed, 22 Apr 2026 17:33:02 +0530 Subject: [PATCH 16/22] Fixing Elasticsearch source filtering. Closes #1274 (#1255) * hardening changes * add regression tests for elastic extraction hardening * Rename constant containing required fields * Sort and extend list of fields to extract * Move hit to dict conversion early for easier handling --------- Co-authored-by: tim <46972822+regulartim@users.noreply.github.com> --- greedybear/consts.py | 26 +++++++++++++-------- greedybear/cronjobs/extraction/pipeline.py | 10 ++++---- greedybear/cronjobs/repositories/elastic.py | 6 ++--- tests/test_elastic_repository.py | 22 +++++++++++++++++ 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/greedybear/consts.py b/greedybear/consts.py index 77093051e..a120418cb 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -15,23 +15,29 @@ DOMAIN = "domain" IP = "ip" -REQUIRED_FIELDS = [ +FIELDS_TO_EXTRACT = [ "@timestamp", - "src_ip", + "body", "dest_port", - "ip_rep", + "duration", + "eventid", "geoip", - "url", + "geoip_ext", + "ip_rep", "message", - "eventid", + "outfile", + "password", + "path", + "post_data", + "protocol", "session", + "shasum", + "src_ip", + "t-pot_ip_ext", "timestamp", - "duration", + "type", + "url", "username", - "password", - "t-pot_ip_ext", - "shasum", - "outfile", ] diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py index c85a1ee85..0765c09d7 100644 --- a/greedybear/cronjobs/extraction/pipeline.py +++ b/greedybear/cronjobs/extraction/pipeline.py @@ -72,24 +72,24 @@ def execute(self) -> int: # 2. Group by honeypot self.log.info("Grouping hits by honeypot type") for hit in chunk: + # convert hit to dict for easier handling + hit = hit.to_dict() # skip hits with non-existing or empty sources if "src_ip" not in hit or not hit["src_ip"].strip(): continue # skip hits with non-existing or empty types (=honeypots) if "type" not in hit or not hit["type"].strip(): continue - # extract sensor and include in hit dict - hit_dict = hit.to_dict() if "t-pot_ip_ext" in hit: sensor = self.sensor_repo.get_or_create_sensor(hit["t-pot_ip_ext"]) - hit_dict["_sensor"] = sensor # include sensor for strategies + hit["_sensor"] = sensor # include sensor for strategies - sensor_country = hit_dict.get("geoip_ext", {}).get("country_name") + sensor_country = hit.get("geoip_ext", {}).get("country_name") if sensor_country is not None: self.sensor_repo.update_country(sensor, sensor_country) - hits_by_honeypot[hit["type"]].append(hit_dict) + hits_by_honeypot[hit["type"]].append(hit) # 3. Extract using strategies for honeypot, hits in sorted(hits_by_honeypot.items()): diff --git a/greedybear/cronjobs/repositories/elastic.py b/greedybear/cronjobs/repositories/elastic.py index 0ca0716ff..55686ee2d 100644 --- a/greedybear/cronjobs/repositories/elastic.py +++ b/greedybear/cronjobs/repositories/elastic.py @@ -5,7 +5,7 @@ from django.conf import settings from elasticsearch.dsl import Q, Search -from greedybear.consts import REQUIRED_FIELDS +from greedybear.consts import FIELDS_TO_EXTRACT from greedybear.settings import EXTRACTION_INTERVAL @@ -56,7 +56,7 @@ def search(self, minutes_back_to_lookup: int) -> Iterator[list]: minutes_back_to_lookup: Number of minutes to look back from the current time. Yields: - list: Log entries sorted by @timestamp for each chunk, containing only REQUIRED_FIELDS. + list: Log entries sorted by @timestamp for each chunk, containing only FIELDS_TO_EXTRACT. Raises: ElasticServerDownError: If Elasticsearch is unreachable. @@ -72,7 +72,7 @@ def search(self, minutes_back_to_lookup: int) -> Iterator[list]: search = Search(using=self.elastic_client, index="logstash-*") q = Q("range", **{"@timestamp": {"gte": chunk_start, "lt": chunk_end}}) search = search.query(q) - search.source(REQUIRED_FIELDS) + search = search.source(FIELDS_TO_EXTRACT) result = list(search.scan()) self.log.debug(f"found {len(result)} hits") result.sort(key=lambda hit: hit["@timestamp"]) diff --git a/tests/test_elastic_repository.py b/tests/test_elastic_repository.py index 04b8e92d9..083456416 100644 --- a/tests/test_elastic_repository.py +++ b/tests/test_elastic_repository.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from unittest.mock import Mock, call, patch +from greedybear.consts import FIELDS_TO_EXTRACT from greedybear.cronjobs.repositories import ElasticRepository, get_time_window from . import CustomTestCase @@ -113,6 +114,27 @@ def test_search_uses_time_window(self, mock_get_time_window, mock_search_class): mock_get_time_window.assert_called_once() + @patch("greedybear.cronjobs.repositories.elastic.get_time_window") + @patch("greedybear.cronjobs.repositories.elastic.Search") + def test_search_scans_reassigned_source_filtered_search(self, mock_search_class, mock_get_time_window): + base_search = Mock() + filtered_search = Mock() + mock_search_class.return_value = base_search + base_search.query.return_value = base_search + base_search.source.return_value = filtered_search + filtered_search.scan.return_value = iter([{"@timestamp": 1}]) + mock_get_time_window.return_value = (datetime(2025, 1, 1, 12, 0), datetime(2025, 1, 1, 12, 10)) + + chunks = list(self.repo.search(minutes_back_to_lookup=10)) + + self.assertEqual(chunks, [[{"@timestamp": 1}]]) + base_search.source.assert_called_once_with(FIELDS_TO_EXTRACT) + filtered_search.scan.assert_called_once() + base_search.scan.assert_not_called() + + def test_fields_to_extract_include_type_for_trending_bucketing(self): + self.assertIn("type", FIELDS_TO_EXTRACT) + class TestSearchChunking(CustomTestCase): """Tests for the chunked iteration behavior of search().""" From 039c9edc2280b4bae0807d22b062b976e2df14e8 Mon Sep 17 00:00:00 2001 From: Manik Date: Wed, 22 Apr 2026 17:42:35 +0530 Subject: [PATCH 17/22] fix: remove host header cache poisoning vector. Closes #1104 (#1251) * remove dead cache.set for current_site in LoginView * make HOST_URI configurable via environment variable * add regression test for host header cache poisoning * chore: fix import sorting in views.py * docs: add HOST_URI to env_file_template --- authentication/views.py | 4 ---- docker/env_file_template | 4 ++++ greedybear/settings.py | 2 +- tests/authentication/test_auth.py | 8 ++++++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/authentication/views.py b/authentication/views.py index d6df05397..fc02600f9 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -6,7 +6,6 @@ from certego_saas.ext.throttling import POSTUserRateThrottle from django.conf import settings from django.contrib.auth import get_user_model, login -from django.core.cache import cache from durin import views as durin_views from durin.models import AuthToken from rest_framework import status @@ -142,9 +141,6 @@ def post(self, request, *args, **kwargs): logger.info(f"administrator:'{uname}' was logged in.") except Exception: logger.exception(f"administrator:'{uname}' login failed.") - # just a hacky way to store the current host - # as this is the first endpoint hit by a user. - cache.set("current_site", request.get_host(), timeout=60 * 60 * 24) return response diff --git a/docker/env_file_template b/docker/env_file_template index fd4344766..a909c7792 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -7,6 +7,10 @@ DJANGO_SECRET= # Example: DJANGO_ALLOWED_HOSTS=greedybear.example.com,api.example.com DJANGO_ALLOWED_HOSTS= +# Public URI used for email verification and password reset links. Defaults to http://localhost. +# Example: HOST_URI=https://api.greedybear.example.com +HOST_URI= + DB_HOST=postgres DB_PORT=5432 DB_USER=user diff --git a/greedybear/settings.py b/greedybear/settings.py index eeeb26e64..c364a4b4c 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -90,7 +90,7 @@ ALLOWED_HOSTS = ["*"] # certego_saas -HOST_URI = "http://localhost" +HOST_URI = os.environ.get("HOST_URI", "http://localhost") HOST_NAME = "GreedyBear" # Application definition diff --git a/tests/authentication/test_auth.py b/tests/authentication/test_auth.py index 721e13143..0367fee1d 100644 --- a/tests/authentication/test_auth.py +++ b/tests/authentication/test_auth.py @@ -61,6 +61,14 @@ def test_login_200(self): self.assertEqual(AuthToken.objects.count(), 1) + def test_login_does_not_set_current_site_cache(self): + """Login must not store the Host header in global cache.""" + cache.clear() + body = {**self.creds} + response = self.client.post(login_uri, body) + self.assertEqual(response.status_code, 200) + self.assertIsNone(cache.get("current_site")) + def test_logout_204(self): self.assertEqual(AuthToken.objects.count(), 0) From ccc5c8d09096da636c49f41689750c3c6864e207 Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:54:07 +0200 Subject: [PATCH 18/22] Bump uv dependencies --- uv.lock | 58 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/uv.lock b/uv.lock index 954dd08c5..444536986 100644 --- a/uv.lock +++ b/uv.lock @@ -54,30 +54,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.89" +version = "1.42.93" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/0c/f7bccb22b245cabf392816baba20f9e95f78ace7dbc580fd40136e80e732/boto3-1.42.89.tar.gz", hash = "sha256:3e43aacc0801bba9bcd23a8c271c089af297a69565f783fcdd357ae0e330bf1e", size = 113165, upload-time = "2026-04-13T19:36:17.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/ac/e6b2b24d53c830500176f710594efcde626186b5b3c9aead6f8837976956/boto3-1.42.93.tar.gz", hash = "sha256:ff81c6bac708cb95c4f8b27e331ac67d95c6908dd86bcb7b15b8941960f2bc4c", size = 113218, upload-time = "2026-04-21T21:30:39.733Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/33/55103ba5ef9975ea54b8d39e69b76eb6e9fded3beae5f01065e26951a3a1/boto3-1.42.89-py3-none-any.whl", hash = "sha256:6204b189f4d0c655535f43d7eaa57ff4e8d965b8463c97e45952291211162932", size = 140556, upload-time = "2026-04-13T19:36:13.894Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2d/fcc35bde9fa47ac463a3023c73838e23e9281cde7f5e86fe7c459c3b72aa/boto3-1.42.93-py3-none-any.whl", hash = "sha256:51e34e30e65bea4df0ff77f91abdcb97297eb74c3b27eb576b2abbd758452967", size = 140554, upload-time = "2026-04-21T21:30:36.581Z" }, ] [[package]] name = "botocore" -version = "1.42.89" +version = "1.42.93" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/cc/e6be943efa9051bd15c2ee14077c2b10d6e27c9e9385fc43a03a5c4ed8b5/botocore-1.42.89.tar.gz", hash = "sha256:95ac52f472dad29942f3088b278ab493044516c16dbf9133c975af16527baa99", size = 15206290, upload-time = "2026-04-13T19:36:02.321Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/d4/eb53f7ed81836696abf7103c9c901a0cace9217328094ca93419016a78c9/botocore-1.42.93.tar.gz", hash = "sha256:9ce49863c50b43f7942edd295fb16bfc6d227264ce6fc32c8f2426ef11b9351b", size = 15239759, upload-time = "2026-04-21T21:30:23.707Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0c/ccc57c9a7bcd4553620bf6f50a3640ba68d189330fc4787dbddb2d851534/botocore-1.42.93-py3-none-any.whl", hash = "sha256:96ae26cd6302a7c7563398517b90a438168a4efdf4f73ab38882cefb8df721cc", size = 14923656, upload-time = "2026-04-21T21:30:17.597Z" }, ] [[package]] @@ -109,11 +109,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -571,11 +571,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, ] [[package]] @@ -981,24 +981,22 @@ sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711 [[package]] name = "simplejson" -version = "3.20.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, - { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, - { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, - { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, - { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, - { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, - { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, - { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, - { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, - { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/91/b0e7a38d63706dde006d1213f9c394ad7702df841c019fb4cf0e3295c58c/simplejson-4.0.1.tar.gz", hash = "sha256:bc13170567a5c856a0e6c16620c0b0388722f7d6382acd8007857624c3dedf3e", size = 115959, upload-time = "2026-04-18T22:46:17.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/94/afe6285b3b39208473ab9056039cf20cac393d1a7942f644ecb7d424463b/simplejson-4.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f934a918ef7a50698a481e18aa713d3075f94d1402a6d293f755d00118f8a975", size = 110975, upload-time = "2026-04-18T22:44:55.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/2c74fdb851af00c18ae11af978e7191972071e26ffe2f5aaccbd0a96e961/simplejson-4.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2b0211da4be9fcbabe11ff9b65f7e06dd8205197b078fe7f1e9cc268cd0e369", size = 90384, upload-time = "2026-04-18T22:44:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/92/af/f457958ef90935e99e1bdf51c16b2a0448726a4db2881d472d22f0259290/simplejson-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:58c125ee57f2a35081c46ca66d76ee3c109394f4aee0d981cb049c5ee5688e62", size = 90387, upload-time = "2026-04-18T22:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/43/58/da3da211a3b91ff0f8b9a3926e3b97b9224cbb94e4a67b998394c0e984a3/simplejson-4.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:24ca278f0ba7b3aff90ed1f873f5f4192ae1da97de72196b4be021f3b1d407a0", size = 186193, upload-time = "2026-04-18T22:45:00.108Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/5757f7eddea36d55593423c5da72cdb1ffd2ae7755d804da08f598cf277a/simplejson-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a6b3d15ab9c8247f7e0a280b2823eff08bd74e7605701c01d7cd54e80a86ed", size = 183647, upload-time = "2026-04-18T22:45:01.484Z" }, + { url = "https://files.pythonhosted.org/packages/1e/27/eb59ff58a78e57db4120e10bc6b7d6409f9c25180b912593c2cfe5637b5c/simplejson-4.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:534a7d90b86a97cf15ebe42d732c41223612d981d08f226025499177692a977d", size = 191880, upload-time = "2026-04-18T22:45:03.067Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/0650672c0b43b9add2c2e5f13f5b2d3770b23bc10688dfe4ea637ef1a4b4/simplejson-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e2b10899f200b675a8ab0e2abd4c4846d2631d90d226fc3079e8b16073aba31d", size = 180312, upload-time = "2026-04-18T22:45:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/00/b9/4a4042c8f24bb53ca03a2d5d794aeefa78965df1213239cf9fe895b40342/simplejson-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dd525b897132493aa65eecd3c5ddc3602e25c625caafbcc958fc87a74a24a1c1", size = 188278, upload-time = "2026-04-18T22:45:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/17/b4/594be43d55ea5100334bc60013c3b310ecde4135b8288dd8183d21881966/simplejson-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:072a481be83b8c3f9167d0463105e0ddfcf2e00a6289c2ad650c3b35920ce0c8", size = 183928, upload-time = "2026-04-18T22:45:07.298Z" }, + { url = "https://files.pythonhosted.org/packages/0d/26/8c09ba0fcf0b8c284a4f0f57605890c443b59cec070c6918baa7600976d9/simplejson-4.0.1-cp313-cp313-win32.whl", hash = "sha256:91eb4b42ab0a2de89919ed3bfa960fca0b13af0816447c89123c6658c36fb0a1", size = 88305, upload-time = "2026-04-18T22:45:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/996ef6a0bd5b02bb9880901df01621b6c6de599360454a2ee2d06985076f/simplejson-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d2dc202a5e9d07e893c0bc1aa12b88bff98332c52b3ae184defe054d0e2ff35", size = 90273, upload-time = "2026-04-18T22:45:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2d/93a5b862ac29f182a658eb3fc2c98fe28acbb5c05a9076402572c7eb6966/simplejson-4.0.1-py3-none-any.whl", hash = "sha256:dfa6e9923c0ec2880738d09e5ce045741eb6cd4551e261dcd6c3625d26666075", size = 69242, upload-time = "2026-04-18T22:46:16.183Z" }, ] [[package]] From c88354d345a2d2396bc248f00c28eece947d3e8d Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:58:08 +0200 Subject: [PATCH 19/22] Bump npm dependencies --- frontend/package-lock.json | 358 +++++++++++++++++++++---------------- frontend/package.json | 6 +- 2 files changed, 206 insertions(+), 158 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f81d4c48d..bfb3187c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,7 +32,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.4", + "@vitest/coverage-v8": "^4.1.5", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -42,8 +42,8 @@ "jsdom": "^29.0.2", "prettier": "^3.8.3", "stylelint": "^17.8.0", - "vite": "^8.0.8", - "vitest": "^4.1.4" + "vite": "^8.0.9", + "vitest": "^4.1.5" } }, "node_modules/@adobe/css-tools": { @@ -71,9 +71,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", - "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -980,9 +980,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", "dev": true, "license": "MIT", "funding": { @@ -1112,6 +1112,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1132,6 +1135,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1152,6 +1158,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1172,6 +1181,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1192,6 +1204,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1212,6 +1227,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1305,9 +1323,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", "cpu": [ "arm64" ], @@ -1322,9 +1340,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", "cpu": [ "arm64" ], @@ -1339,9 +1357,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", "cpu": [ "x64" ], @@ -1356,9 +1374,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", "cpu": [ "x64" ], @@ -1373,9 +1391,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", "cpu": [ "arm" ], @@ -1390,13 +1408,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1407,13 +1428,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1424,13 +1448,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1441,13 +1468,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1458,13 +1488,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1475,13 +1508,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1492,9 +1528,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", "cpu": [ "arm64" ], @@ -1509,9 +1545,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", "cpu": [ "wasm32" ], @@ -1521,16 +1557,16 @@ "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", "cpu": [ "arm64" ], @@ -1545,9 +1581,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", "cpu": [ "x64" ], @@ -1923,14 +1959,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", - "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1944,8 +1980,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.4", - "vitest": "4.1.4" + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1954,16 +1990,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1972,13 +2008,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1999,9 +2035,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -2012,13 +2048,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -2026,14 +2062,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2042,9 +2078,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -2052,13 +2088,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3348,13 +3384,13 @@ "license": "MIT" }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -4786,9 +4822,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5879,6 +5915,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5900,6 +5939,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5921,6 +5963,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5942,6 +5987,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6346,9 +6394,9 @@ } }, "node_modules/nano-css/node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, "node_modules/nanoid": { @@ -6718,13 +6766,13 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -7603,14 +7651,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7619,27 +7667,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", "dev": true, "license": "MIT" }, @@ -7677,15 +7725,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -9070,17 +9118,17 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -9148,19 +9196,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -9188,12 +9236,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.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index 9ab55fcbc..c25515bc0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.4", + "@vitest/coverage-v8": "^4.1.5", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -61,7 +61,7 @@ "jsdom": "^29.0.2", "prettier": "^3.8.3", "stylelint": "^17.8.0", - "vite": "^8.0.8", - "vitest": "^4.1.4" + "vite": "^8.0.9", + "vitest": "^4.1.5" } } From a034717452a37ece1731d6279b2d93f1fa6f52fa Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:30:59 +0200 Subject: [PATCH 20/22] Exclude disabled honeypots from activity buckets. Closes #1275 (#1276) * Add admin view for activity buckets * Move bucket update to run after honeypot readiness check * Add tests * Adapt existing test cases * Move bucket logic into extraction/bucket_updater.py and turning it into a stateful object * Adapt tests * Clear counter on failure --- greedybear/admin.py | 11 +++ greedybear/cronjobs/bucket_utils.py | 60 ------------ .../cronjobs/extraction/bucket_updater.py | 67 ++++++++++++++ greedybear/cronjobs/extraction/pipeline.py | 19 ++-- .../test_extraction_pipeline_edge_cases.py | 91 +++++++++++++++++-- tests/greedybear/cronjobs/test_trending.py | 61 ++++++++----- 6 files changed, 212 insertions(+), 97 deletions(-) delete mode 100644 greedybear/cronjobs/bucket_utils.py create mode 100644 greedybear/cronjobs/extraction/bucket_updater.py diff --git a/greedybear/admin.py b/greedybear/admin.py index 3372630f2..96211f8a1 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -8,6 +8,7 @@ from greedybear.models import ( IOC, + AttackerActivityBucket, CommandSequence, CowrieSession, Credential, @@ -183,6 +184,16 @@ def get_queryset(self, request): return super().get_queryset(request).select_related("autonomous_system").prefetch_related("sensors", "honeypots") +@admin.register(AttackerActivityBucket) +class AttackerActivityBucketAdmin(admin.ModelAdmin): + list_display = ["attacker_ip", "feed_type", "bucket_start", "interaction_count"] + list_filter = ["feed_type"] + search_fields = ["attacker_ip"] + search_help_text = "search for the attacker IP address" + date_hierarchy = "bucket_start" + ordering = ["-bucket_start"] + + @admin.register(Honeypot) class HoneypotAdmin(admin.ModelAdmin): list_display = [ diff --git a/greedybear/cronjobs/bucket_utils.py b/greedybear/cronjobs/bucket_utils.py deleted file mode 100644 index 82548fa65..000000000 --- a/greedybear/cronjobs/bucket_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -import logging -from collections import Counter -from collections.abc import Iterable -from datetime import datetime -from ipaddress import ip_address - -from elasticsearch.dsl.response import Hit - -from greedybear.cronjobs.extraction.utils import parse_timestamp -from greedybear.cronjobs.repositories import TrendingBucketRepository -from greedybear.utils import is_non_global_ip - -logger = logging.getLogger(__name__) - -BucketKey = tuple[str, str, datetime] - - -def _bucket_start(timestamp: str) -> datetime: - parsed = parse_timestamp(timestamp) - return parsed.replace(minute=0, second=0, microsecond=0) - - -def _bucket_key_from_hit(hit: Hit) -> BucketKey | None: - hit_dict = hit.to_dict() - attacker_ip = hit_dict.get("src_ip") - feed_type = hit_dict.get("type") - timestamp = hit_dict.get("@timestamp") - if not attacker_ip or not feed_type or not timestamp: - return None - - normalized_ip = str(attacker_ip) - try: - parsed_ip = ip_address(normalized_ip) - except ValueError: - return None - - if is_non_global_ip(parsed_ip): - return None - - try: - return normalized_ip, str(feed_type).lower(), _bucket_start(timestamp) - except Exception: - return None - - -def update_activity_buckets_from_hits(hits: Iterable[Hit]) -> int: - counters: Counter[BucketKey] = Counter() - for hit in hits: - key = _bucket_key_from_hit(hit) - if key is not None: - counters[key] += 1 - - if not counters: - return 0 - - try: - return TrendingBucketRepository().upsert_bucket_counts(counters) - except Exception as exc: - logger.error("Failed to update activity buckets from hits for current chunk: %s", exc, exc_info=True) - return 0 diff --git a/greedybear/cronjobs/extraction/bucket_updater.py b/greedybear/cronjobs/extraction/bucket_updater.py new file mode 100644 index 000000000..dd57a07c0 --- /dev/null +++ b/greedybear/cronjobs/extraction/bucket_updater.py @@ -0,0 +1,67 @@ +import logging +from collections import Counter +from collections.abc import Iterable +from datetime import datetime +from ipaddress import ip_address + +from greedybear.cronjobs.extraction.utils import parse_timestamp +from greedybear.cronjobs.repositories import TrendingBucketRepository +from greedybear.utils import is_non_global_ip + +logger = logging.getLogger(__name__) + +BucketKey = tuple[str, str, datetime] + + +class BucketUpdater: + def __init__(self): + self.counters: Counter[BucketKey] = Counter() + self.total_update_count: int = 0 + + def collect_hits(self, hits: Iterable[dict]) -> None: + for hit in hits: + key = _bucket_key_from_hit(hit) + if key is not None: + self.counters[key] += 1 + + def update(self) -> int: + if not self.counters: + return 0 + + try: + update_count = TrendingBucketRepository().upsert_bucket_counts(self.counters) + logger.debug(f"Updated {update_count} buckets") + self.total_update_count += update_count + return update_count + except Exception as exc: + logger.error("Failed to update activity buckets from hits for current chunk: %s", exc, exc_info=True) + return 0 + finally: + self.counters = Counter() + + +def _bucket_start(timestamp: str) -> datetime: + parsed = parse_timestamp(timestamp) + return parsed.replace(minute=0, second=0, microsecond=0) + + +def _bucket_key_from_hit(hit: dict) -> BucketKey | None: + attacker_ip = hit.get("src_ip") + feed_type = hit.get("type") + timestamp = hit.get("@timestamp") + if not attacker_ip or not feed_type or not timestamp: + return None + + normalized_ip = str(attacker_ip) + try: + parsed_ip = ip_address(normalized_ip) + except ValueError: + return None + + if is_non_global_ip(parsed_ip): + return None + + try: + return normalized_ip, str(feed_type).lower(), _bucket_start(timestamp) + except Exception: + return None diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py index 0765c09d7..a7e3e64a3 100644 --- a/greedybear/cronjobs/extraction/pipeline.py +++ b/greedybear/cronjobs/extraction/pipeline.py @@ -3,7 +3,7 @@ from django.core.cache import caches -from greedybear.cronjobs.bucket_utils import update_activity_buckets_from_hits +from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory from greedybear.cronjobs.repositories import ( ElasticRepository, @@ -53,12 +53,13 @@ def execute(self) -> int: 2. For each chunk, group hits by honeypot type and extract sensors 3. Apply honeypot-specific extraction strategies 4. Update IOC scores + 5. Update activity buckets Returns: Number of IOC records processed. """ ioc_record_count = 0 - bucket_update_count = 0 + bucket_updater = BucketUpdater() factory = ExtractionStrategyFactory(self.ioc_repo, self.sensor_repo) # 1. Search in chunks @@ -67,8 +68,6 @@ def execute(self) -> int: ioc_records = [] hits_by_honeypot = defaultdict(list) - bucket_update_count += update_activity_buckets_from_hits(chunk) - # 2. Group by honeypot self.log.info("Grouping hits by honeypot type") for hit in chunk: @@ -96,6 +95,10 @@ def execute(self) -> int: if not self.ioc_repo.is_ready_for_extraction(honeypot): self.log.info(f"Skipping honeypot {honeypot}") continue + + self.log.info(f"Collect hits for activity buckets from honeypot {honeypot}") + bucket_updater.collect_hits(hits) + self.log.info(f"Extracting hits from honeypot {honeypot}") strategy = factory.get_strategy(honeypot) try: @@ -110,7 +113,11 @@ def execute(self) -> int: UpdateScores().score_only(ioc_records) ioc_record_count += len(ioc_records) - # 5. Invalidate API caches only if any IOC records were processed + # 5. Update activity buckets + self.log.info("Updating activity buckets") + bucket_updater.update() + + # 6. Invalidate API caches only if any IOC records were processed if ioc_record_count > 0: # Use the shared DB-backed cache so the version bump is visible to # gunicorn API workers (LocMemCache is per-process). @@ -121,7 +128,7 @@ def execute(self) -> int: except ValueError: shared_cache.set("asn_feeds_version", 2, timeout=None) - if bucket_update_count > 0: + if bucket_updater.total_update_count > 0: self.log.info("Invalidating feeds trending cache") shared_cache = caches["django-q"] try: diff --git a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py index d864a8cff..a0561d542 100644 --- a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py +++ b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py @@ -60,10 +60,10 @@ def test_partial_strategy_success(self, mock_factory, mock_scores): # Scoring should be called with successful IOCs mock_scores.return_value.score_only.assert_called_once() - @patch("greedybear.cronjobs.extraction.pipeline.update_activity_buckets_from_hits", return_value=0) + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") - def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_factory, mock_scores, _mock_update_activity): + def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_factory, mock_scores, mock_bucket_updater_cls): pipeline = self._create_pipeline_with_real_factory() pipeline.log = MagicMock() @@ -74,6 +74,9 @@ def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_fac pipeline.ioc_repo.is_empty.return_value = False pipeline.ioc_repo.is_ready_for_extraction.return_value = True + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 0 + mock_success = MagicMock() mock_success.ioc_records = [self._create_mock_ioc("2.2.2.2")] mock_factory.return_value.get_strategy.return_value = mock_success @@ -83,13 +86,14 @@ def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_fac self.assertEqual(result, 1) mock_success.extract_from_hits.assert_called_once() mock_scores.return_value.score_only.assert_called_once() - _mock_update_activity.assert_called_once() + bucket_updater.collect_hits.assert_called_once() + bucket_updater.update.assert_called_once() @patch("greedybear.cronjobs.extraction.pipeline.caches") - @patch("greedybear.cronjobs.extraction.pipeline.update_activity_buckets_from_hits", return_value=2) + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") - def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_scores, _mock_update_activity, mock_caches): + def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): pipeline = self._create_pipeline_with_real_factory() pipeline.log = MagicMock() @@ -98,7 +102,14 @@ def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_score ] pipeline.elastic_repo.search.return_value = [hits] pipeline.ioc_repo.is_empty.return_value = False - pipeline.ioc_repo.is_ready_for_extraction.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.return_value = True + + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 2 + + mock_strategy = MagicMock() + mock_strategy.ioc_records = [] + mock_factory.return_value.get_strategy.return_value = mock_strategy shared_cache = MagicMock() mock_caches.__getitem__.return_value = shared_cache @@ -106,10 +117,76 @@ def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_score result = pipeline.execute() self.assertEqual(result, 0) - _mock_update_activity.assert_called_once() + bucket_updater.collect_hits.assert_called_once() + bucket_updater.update.assert_called_once() shared_cache.incr.assert_called_once_with("trending_feeds_version") mock_scores.return_value.score_only.assert_not_called() + + @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_disabled_honeypot_hits_do_not_update_activity_buckets(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + """Hits from honeypots not ready for extraction must be excluded from bucket updates.""" + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledHoneypot"}), + MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledHoneypot"}), + MockElasticHit({"src_ip": "2.2.2.2", "type": "EnabledHoneypot"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.side_effect = lambda hp: hp == "EnabledHoneypot" + + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 0 + + mock_strategy = MagicMock() + mock_strategy.ioc_records = [] + mock_factory.return_value.get_strategy.return_value = mock_strategy + + shared_cache = MagicMock() + mock_caches.__getitem__.return_value = shared_cache + + pipeline.execute() + + # collect_hits must be called exactly once, only with hits from the enabled honeypot. + bucket_updater.collect_hits.assert_called_once() + passed_hits = list(bucket_updater.collect_hits.call_args.args[0]) + self.assertEqual(len(passed_hits), 1) + self.assertEqual(passed_hits[0]["type"], "EnabledHoneypot") + self.assertEqual(passed_hits[0]["src_ip"], "2.2.2.2") + + @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_all_honeypots_disabled_skips_bucket_updates_entirely(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + """When every honeypot in a chunk is disabled, no hits are collected and the trending cache is not invalidated.""" + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledA"}), + MockElasticHit({"src_ip": "2.2.2.2", "type": "DisabledB"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.return_value = False + + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 0 + + shared_cache = MagicMock() + mock_caches.__getitem__.return_value = shared_cache + + pipeline.execute() + + bucket_updater.collect_hits.assert_not_called() mock_factory.return_value.get_strategy.assert_not_called() + shared_cache.incr.assert_not_called() class TestLargeBatches(E2ETestCase): diff --git a/tests/greedybear/cronjobs/test_trending.py b/tests/greedybear/cronjobs/test_trending.py index 74b736e12..0ef047eb1 100644 --- a/tests/greedybear/cronjobs/test_trending.py +++ b/tests/greedybear/cronjobs/test_trending.py @@ -2,10 +2,9 @@ from unittest.mock import patch from django.test import SimpleTestCase, override_settings -from elasticsearch.dsl.response import Hit from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron -from greedybear.cronjobs.bucket_utils import update_activity_buckets_from_hits +from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater from greedybear.cronjobs.repositories.trending_bucket import TrendingBucketRepository from greedybear.cronjobs.trending import ( attacker_sort_tuple, @@ -70,13 +69,15 @@ def test_upsert_increments_existing_bucket_and_creates_missing_bucket(self): interaction_count=3, ) - unique_keys = update_activity_buckets_from_hits( + bu = BucketUpdater() + bu.collect_hits( [ - Hit({"_source": {"src_ip": "1.1.1.1", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "1.1.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:50:00"}}), - Hit({"_source": {"src_ip": "2.2.2.2", "type": "Heralding", "@timestamp": "2026-03-20T09:10:00"}}), + {"src_ip": "1.1.1.1", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "1.1.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:50:00"}, + {"src_ip": "2.2.2.2", "type": "Heralding", "@timestamp": "2026-03-20T09:10:00"}, ] ) + unique_keys = bu.update() self.assertEqual(unique_keys, 2) @@ -95,49 +96,59 @@ def test_upsert_increments_existing_bucket_and_creates_missing_bucket(self): self.assertEqual(created_bucket.interaction_count, 1) def test_invalid_hits_are_ignored(self): - unique_keys = update_activity_buckets_from_hits( + bu = BucketUpdater() + bu.collect_hits( [ - Hit({"_source": {"src_ip": "", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "999.999.999.999", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "3.3.3.3", "type": "", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "3.3.3.3", "type": "cowrie"}}), + {"src_ip": "", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "999.999.999.999", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "3.3.3.3", "type": "", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "3.3.3.3", "type": "cowrie"}, ] ) + unique_keys = bu.update() self.assertEqual(unique_keys, 0) self.assertEqual(AttackerActivityBucket.objects.count(), 0) def test_invalid_timestamp_is_ignored(self): - unique_keys = update_activity_buckets_from_hits( + bu = BucketUpdater() + bu.collect_hits( [ - Hit({"_source": {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "not-a-timestamp"}}), + {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "not-a-timestamp"}, ] ) + unique_keys = bu.update() + self.assertEqual(unique_keys, 0) self.assertEqual(AttackerActivityBucket.objects.count(), 0) def test_non_global_ip_hits_are_ignored(self): - unique_keys = update_activity_buckets_from_hits( + bu = BucketUpdater() + bu.collect_hits( [ - Hit({"_source": {"src_ip": "10.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "127.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "224.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "169.254.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "240.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), - Hit({"_source": {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + {"src_ip": "10.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "127.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "224.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "169.254.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "240.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, ] ) + unique_keys = bu.update() self.assertEqual(unique_keys, 1) self.assertEqual(AttackerActivityBucket.objects.count(), 1) self.assertTrue(AttackerActivityBucket.objects.filter(attacker_ip="8.8.8.8").exists()) def test_global_ipv6_hit_is_counted(self): - unique_keys = update_activity_buckets_from_hits( + bu = BucketUpdater() + bu.collect_hits( [ - Hit({"_source": {"src_ip": "2001:4860:4860::8888", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}}), + {"src_ip": "2001:4860:4860::8888", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, ] ) + unique_keys = bu.update() + self.assertEqual(unique_keys, 1) self.assertTrue( AttackerActivityBucket.objects.filter( @@ -165,9 +176,11 @@ def test_upsert_uses_multiple_batches_for_large_counter_sets(self): self.assertEqual(inserted, 5) self.assertEqual(mock_cursor.execute.call_count, 3) - @patch("greedybear.cronjobs.bucket_utils.TrendingBucketRepository.upsert_bucket_counts", side_effect=Exception("db down")) + @patch("greedybear.cronjobs.extraction.bucket_updater.TrendingBucketRepository.upsert_bucket_counts", side_effect=Exception("db down")) def test_upsert_failure_returns_zero(self, mock_upsert): - unique_keys = update_activity_buckets_from_hits([Hit({"_source": {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}})]) + bu = BucketUpdater() + bu.collect_hits([{"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}]) + unique_keys = bu.update() self.assertEqual(unique_keys, 0) mock_upsert.assert_called_once() From 1a87c16564a414d912fc95aada222b53e358d0ae Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:42:52 +0200 Subject: [PATCH 21/22] Bump Ruff in pre-commit config --- .github/.pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/.pre-commit-config.yaml b/.github/.pre-commit-config.yaml index e1f1fa33c..d9f96451b 100644 --- a/.github/.pre-commit-config.yaml +++ b/.github/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: # Python linting with Ruff - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.7 + rev: v0.15.11 hooks: - id: ruff name: ruff-lint From 6207581824a2d8a98be0e6a76f78183d2536391e Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:57:13 +0200 Subject: [PATCH 22/22] Extend and restructure `pyproject.toml` --- docker/Dockerfile | 4 +-- pyproject.toml | 78 ++++++++++++++++++++++++++++++++--------------- uv.lock | 50 +++++++++++++++--------------- 3 files changed, 81 insertions(+), 51 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e5839d3f..d2c02a12e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -76,8 +76,8 @@ RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \ FROM production AS development -# Install dev dependencies -RUN uv sync --group dev --locked +# Install dev + test dependencies (hot-reload tools + test runners) +RUN uv sync --group dev --group test --locked ## ------------------------------- Default Stage ------------------------------ ## diff --git a/pyproject.toml b/pyproject.toml index 66a490429..33cddbe1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,47 +1,60 @@ [project] name = "greedybear" version = "3.4.0" -requires-python = "==3.13.*" +description = "Threat intelligence platform that extracts attack data from a T-Pot or a cluster of them and generates actionable live feeds." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.13,<3.14" dependencies = [ # Django core - "Django==5.2.13", - "djangorestframework==3.17.1", + "Django~=5.2.13", + "djangorestframework~=3.17.1", "django-rest-email-auth==5.0.0", - "django-ses==4.7.2", - "django-q2==1.9.0", - "croniter==6.2.2", + "django-ses~=4.7", + "django-q2~=1.9", + "croniter~=6.2", "certego-saas==0.7.12", # Server Gateway Interface - "gunicorn==25.3.0", + "gunicorn~=25.3", # Data stores "elasticsearch==9.3.0", - "psycopg[c]==3.3.3", + "psycopg[c]~=3.3", # ML / data science - "scikit-learn==1.8.0", - "pandas==3.0.2", - "numpy==2.4.4", - "joblib==1.5.3", - "datasketch==1.10.0", + "scikit-learn~=1.8.0", + "pandas~=3.0", + "numpy~=2.4", + "joblib~=1.5", + "datasketch~=1.10", # File Format Support - "feedparser==6.0.12", - "stix2==3.0.2", + "feedparser~=6.0", + "stix2~=3.0", # Utilities - "requests==2.33.1", - "slack-sdk==3.41.0", + "requests~=2.33", + "slack-sdk~=3.41", ] +[project.urls] +Repository = "https://github.com/GreedyBear-Project/GreedyBear" +Documentation = "https://github.com/GreedyBear-Project/GreedyBear/wiki" +Blog = "https://greedybear-project.github.io/" + [dependency-groups] dev = [ + "django-watchfiles~=1.4", +] +test = [ "coverage==7.13.5", "django-test-migrations==1.5.0", - "django-watchfiles==1.4.0", ] lint = [ "ruff==0.15.11", ] [tool.uv] +# necessary because certego-saas transitively depends on djangorestframework-filters 1.0.0.dev2 (dev prerelease) prerelease = "allow" +# necessary because certego-saas pins Markdown <3.4, but we want a current release for security fixes override-dependencies = ["markdown==3.10.2"] [tool.ruff] @@ -49,6 +62,8 @@ extend-exclude = [ ".github", ".idea", ".vscode", + ".venv", + "venv", "**/migrations/*", ] include = ["*.py"] @@ -68,17 +83,30 @@ skip-magic-trailing-comma = false [tool.ruff.lint] select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "DJ", # flake8-django "E", # pycodestyle errors - "W", # pycodestyle warnings "F", # pyflakes "I", # isort + "ICN", # flake8-import-conventions + "LOG", # flake8-logging "N", # pep8-naming + "TC", # flake8-type-checking "UP", # pyupgrade - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "DJ", # flake8-django + "W", # pycodestyle warnings ] -ignore = [ - "F403", # Allow wildcard imports in __init__.py files - "E501", # Allow long lines in docstrings + +[tool.ruff.lint.per-file-ignores] +# Re-exporting submodules with wildcard imports is intentional in package __init__.py files +"**/__init__.py" = ["F403"] +# Long parameter documentation lines in API view docstrings +"api/views/*" = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = [ + "api", + "authentication", + "configuration", + "greedybear", ] diff --git a/uv.lock b/uv.lock index 444536986..d05a98ba2 100644 --- a/uv.lock +++ b/uv.lock @@ -509,44 +509,46 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "coverage" }, - { name = "django-test-migrations" }, { name = "django-watchfiles" }, ] lint = [ { name = "ruff" }, ] +test = [ + { name = "coverage" }, + { name = "django-test-migrations" }, +] [package.metadata] requires-dist = [ { name = "certego-saas", specifier = "==0.7.12" }, - { name = "croniter", specifier = "==6.2.2" }, - { name = "datasketch", specifier = "==1.10.0" }, - { name = "django", specifier = "==5.2.13" }, - { name = "django-q2", specifier = "==1.9.0" }, + { name = "croniter", specifier = "~=6.2" }, + { name = "datasketch", specifier = "~=1.10" }, + { name = "django", specifier = "~=5.2.13" }, + { name = "django-q2", specifier = "~=1.9" }, { name = "django-rest-email-auth", specifier = "==5.0.0" }, - { name = "django-ses", specifier = "==4.7.2" }, - { name = "djangorestframework", specifier = "==3.17.1" }, + { name = "django-ses", specifier = "~=4.7" }, + { name = "djangorestframework", specifier = "~=3.17.1" }, { name = "elasticsearch", specifier = "==9.3.0" }, - { name = "feedparser", specifier = "==6.0.12" }, - { name = "gunicorn", specifier = "==25.3.0" }, - { name = "joblib", specifier = "==1.5.3" }, - { name = "numpy", specifier = "==2.4.4" }, - { name = "pandas", specifier = "==3.0.2" }, - { name = "psycopg", extras = ["c"], specifier = "==3.3.3" }, - { name = "requests", specifier = "==2.33.1" }, - { name = "scikit-learn", specifier = "==1.8.0" }, - { name = "slack-sdk", specifier = "==3.41.0" }, - { name = "stix2", specifier = "==3.0.2" }, + { name = "feedparser", specifier = "~=6.0" }, + { name = "gunicorn", specifier = "~=25.3" }, + { name = "joblib", specifier = "~=1.5" }, + { name = "numpy", specifier = "~=2.4" }, + { name = "pandas", specifier = "~=3.0" }, + { name = "psycopg", extras = ["c"], specifier = "~=3.3" }, + { name = "requests", specifier = "~=2.33" }, + { name = "scikit-learn", specifier = "~=1.8.0" }, + { name = "slack-sdk", specifier = "~=3.41" }, + { name = "stix2", specifier = "~=3.0" }, ] [package.metadata.requires-dev] -dev = [ +dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] +lint = [{ name = "ruff", specifier = "==0.15.11" }] +test = [ { name = "coverage", specifier = "==7.13.5" }, { name = "django-test-migrations", specifier = "==1.5.0" }, - { name = "django-watchfiles", specifier = "==1.4.0" }, ] -lint = [{ name = "ruff", specifier = "==0.15.11" }] [[package]] name = "gunicorn" @@ -571,11 +573,11 @@ wheels = [ [[package]] name = "idna" -version = "3.12" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]]