From 49c108a21e7d35b1ac9667b58085086e27e0e452 Mon Sep 17 00:00:00 2001 From: SupRaKoshti Date: Thu, 26 Feb 2026 14:30:20 +0530 Subject: [PATCH 001/109] Modernize model fields. Closes #863 (#887) --- ...ter_commandsequence_first_seen_and_more.py | 74 +++++++++++++++++++ greedybear/models.py | 67 ++++++++--------- tests/test_sensor_repository.py | 2 - 3 files changed, 104 insertions(+), 39 deletions(-) create mode 100644 greedybear/migrations/0037_alter_commandsequence_first_seen_and_more.py diff --git a/greedybear/migrations/0037_alter_commandsequence_first_seen_and_more.py b/greedybear/migrations/0037_alter_commandsequence_first_seen_and_more.py new file mode 100644 index 00000000..ec00e6ec --- /dev/null +++ b/greedybear/migrations/0037_alter_commandsequence_first_seen_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.11 on 2026-02-25 04:20 + +import django.db.models.functions.datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0036_add_sensors_to_ioc'), + ] + + operations = [ + migrations.AlterField( + model_name='commandsequence', + name='first_seen', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='commandsequence', + name='last_seen', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='firehollist', + name='added', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='ioc', + name='first_seen', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='ioc', + name='last_seen', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='massscanner', + name='added', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='massscanner', + name='ip_address', + field=models.GenericIPAddressField(), + ), + migrations.AlterField( + model_name='sensor', + name='address', + field=models.GenericIPAddressField(unique=True), + ), + migrations.AlterField( + model_name='statistics', + name='request_date', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='torexitnode', + name='added', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + migrations.AlterField( + model_name='torexitnode', + name='ip_address', + field=models.GenericIPAddressField(unique=True), + ), + migrations.AlterField( + model_name='whatsmyipdomain', + name='added', + field=models.DateTimeField(db_default=django.db.models.functions.datetime.Now()), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index c2a8b316..8b1dfbc0 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -1,10 +1,8 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. -from datetime import datetime - from django.contrib.postgres import fields as pg_fields from django.db import models -from django.db.models.functions import Lower +from django.db.models.functions import Lower, Now class ViewType(models.TextChoices): @@ -20,15 +18,15 @@ class IocType(models.TextChoices): class Sensor(models.Model): - address = models.CharField(max_length=15, blank=False, unique=True) + address = models.GenericIPAddressField(unique=True) def __str__(self): return self.address class GeneralHoneypot(models.Model): - name = models.CharField(max_length=15, blank=False) - active = models.BooleanField(blank=False, default=True) + name = models.CharField(max_length=15) + active = models.BooleanField(default=True) class Meta: constraints = [models.UniqueConstraint(Lower("name"), name="unique_generalhoneypot_name_ci")] @@ -38,8 +36,8 @@ def __str__(self): class FireHolList(models.Model): - ip_address = models.CharField(max_length=256, blank=False) - added = models.DateTimeField(blank=False, default=datetime.now) + ip_address = models.CharField(max_length=256) + added = models.DateTimeField(db_default=Now()) source = models.CharField(max_length=64, blank=True, default="") class Meta: @@ -52,10 +50,10 @@ def __str__(self): class IOC(models.Model): - name = models.CharField(max_length=256, blank=False) - type = models.CharField(max_length=32, blank=False, choices=IocType.choices) - first_seen = models.DateTimeField(blank=False, default=datetime.now) - last_seen = models.DateTimeField(blank=False, default=datetime.now) + name = models.CharField(max_length=256) + type = models.CharField(max_length=32, choices=IocType.choices) + first_seen = models.DateTimeField(db_default=Now()) + last_seen = models.DateTimeField(db_default=Now()) days_seen = pg_fields.ArrayField(models.DateField(), blank=True, default=list) number_of_days_seen = models.IntegerField(default=1) attack_count = models.IntegerField(default=1) @@ -64,18 +62,18 @@ class IOC(models.Model): general_honeypot = models.ManyToManyField(GeneralHoneypot, blank=True) # SENSORS - list of T-Pot sensors that detected this IOC sensors = models.ManyToManyField(Sensor, blank=True) - scanner = models.BooleanField(blank=False, default=False) - payload_request = models.BooleanField(blank=False, default=False) + scanner = models.BooleanField(default=False) + payload_request = models.BooleanField(default=False) related_ioc = models.ManyToManyField("self", blank=True, symmetrical=True) related_urls = pg_fields.ArrayField(models.CharField(max_length=900, blank=True), blank=True, default=list) ip_reputation = models.CharField(max_length=32, blank=True) firehol_categories = pg_fields.ArrayField(models.CharField(max_length=64, blank=True), blank=True, default=list) asn = models.IntegerField(blank=True, null=True) - destination_ports = pg_fields.ArrayField(models.IntegerField(), blank=False, null=False, default=list) - login_attempts = models.IntegerField(blank=False, null=False, default=0) + destination_ports = pg_fields.ArrayField(models.IntegerField(), default=list) + login_attempts = models.IntegerField(default=0) # SCORES - recurrence_probability = models.FloatField(blank=False, null=True, default=0) - expected_interactions = models.FloatField(blank=False, null=True, default=0) + recurrence_probability = models.FloatField(null=True, default=0) + expected_interactions = models.FloatField(null=True, default=0) class Meta: indexes = [ @@ -87,12 +85,10 @@ def __str__(self): class CommandSequence(models.Model): - first_seen = models.DateTimeField(blank=False, default=datetime.now) - last_seen = models.DateTimeField(blank=False, default=datetime.now) + first_seen = models.DateTimeField(db_default=Now()) + last_seen = models.DateTimeField(db_default=Now()) commands = pg_fields.ArrayField( models.CharField(max_length=1024, blank=True), - blank=False, - null=False, default=list, ) commands_hash = models.CharField(max_length=64, unique=True, blank=True, null=True) @@ -107,16 +103,14 @@ class CowrieSession(models.Model): session_id = models.BigIntegerField(primary_key=True) start_time = models.DateTimeField(blank=True, null=True) duration = models.FloatField(blank=True, null=True) - login_attempt = models.BooleanField(blank=False, null=False, default=False) + login_attempt = models.BooleanField(default=False) credentials = pg_fields.ArrayField( models.CharField(max_length=256, blank=True), - blank=False, - null=False, default=list, ) - command_execution = models.BooleanField(blank=False, null=False, default=False) - interaction_count = models.IntegerField(blank=False, null=False, default=0) - source = models.ForeignKey(IOC, on_delete=models.CASCADE, blank=False, null=False) + command_execution = models.BooleanField(default=False) + interaction_count = models.IntegerField(default=0) + source = models.ForeignKey(IOC, on_delete=models.CASCADE) commands = models.ForeignKey(CommandSequence, on_delete=models.SET_NULL, blank=True, null=True) class Meta: @@ -129,22 +123,21 @@ def __str__(self): class Statistics(models.Model): - source = models.CharField(max_length=15, blank=False) + source = models.CharField(max_length=15) view = models.CharField( max_length=32, - blank=False, choices=ViewType.choices, default=ViewType.FEEDS_VIEW.value, ) - request_date = models.DateTimeField(blank=False, default=datetime.now) + request_date = models.DateTimeField(db_default=Now()) def __str__(self): return f"{self.source} - {self.view} ({self.request_date.strftime('%Y-%m-%d %H:%M')})" class MassScanner(models.Model): - ip_address = models.CharField(max_length=256, blank=False) - added = models.DateTimeField(blank=False, default=datetime.now) + ip_address = models.GenericIPAddressField() + added = models.DateTimeField(db_default=Now()) reason = models.CharField(max_length=64, blank=True, default="") class Meta: @@ -157,8 +150,8 @@ def __str__(self): class TorExitNode(models.Model): - ip_address = models.CharField(max_length=256, blank=False, unique=True) - added = models.DateTimeField(blank=False, default=datetime.now) + ip_address = models.GenericIPAddressField(unique=True) + added = models.DateTimeField(db_default=Now()) reason = models.CharField(max_length=64, blank=True, default="tor exit node") class Meta: @@ -171,8 +164,8 @@ def __str__(self): class WhatsMyIPDomain(models.Model): - domain = models.CharField(max_length=256, blank=False) - added = models.DateTimeField(blank=False, default=datetime.now) + domain = models.CharField(max_length=256) + added = models.DateTimeField(db_default=Now()) class Meta: indexes = [ diff --git a/tests/test_sensor_repository.py b/tests/test_sensor_repository.py index 09c9f626..21985555 100644 --- a/tests/test_sensor_repository.py +++ b/tests/test_sensor_repository.py @@ -25,12 +25,10 @@ def test_get_or_create_sensor_returns_existing_sensor(self): def test_get_or_create_sensor_rejects_non_ip(self): result = self.repo.get_or_create_sensor("not-an-ip") self.assertIsNone(result) - self.assertFalse(Sensor.objects.filter(address="not-an-ip").exists()) def test_get_or_create_sensor_rejects_domain(self): result = self.repo.get_or_create_sensor("example.com") self.assertIsNone(result) - self.assertFalse(Sensor.objects.filter(address="example.com").exists()) def test_cache_populated_on_init(self): Sensor.objects.create(address="192.168.1.1") From da9793b22c932ade7d4e9b1b24f2ebae5c5e8ab0 Mon Sep 17 00:00:00 2001 From: Krishna Awasthi <140143710+opbot-xd@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:25:01 +0530 Subject: [PATCH 002/109] Add IP enrichment via ThreatFox and AbuseIPDB. Progresses #522 (#867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add IP enrichment via ThreatFox and AbuseIPDB (fetch-and-join approach) Implement IP enrichment using the 'fetch and join directly' strategy: cronjobs download feed data from ThreatFox/AbuseIPDB APIs and directly join against the IOC table to create Tag entries. No local cache tables needed — tags are replaced atomically on each run for data freshness. New files: - Tag model (ForeignKey to IOC, key/value/source fields) - TagRepository with bulk replace_tags_for_source() - ThreatFoxCron: fetches IOCs, extracts IPs, creates malware/threat tags - AbuseIPDBCron: fetches blacklist, creates abuse confidence/country tags - Weekly schedules for both enrichment cronjobs - ABUSEIPDB_API_KEY env var in settings and env_file_template Tests: - 31 new tests for Tag model, TagRepository, ThreatFoxCron, AbuseIPDBCron - Fix 2 pre-existing test failures in test_cowrie_extraction (mock issues) Closes #522 * Address review: transaction.atomic, reuse delete method, blocklist naming, db_default - Wrap replace_tags_for_source in transaction.atomic() for atomicity - Reuse delete_tags_by_source inside replace_tags_for_source - Rename blacklist -> blocklist in variable names, comments, docstrings - Use db_default=Now() instead of default=datetime.now on Tag.added - Regenerate migration with db_default=Now() baked in * fix: Ensure accurate and efficient counting of matched IOCs in ThreatFox and AbuseIPDB feed processing logs. * Address review: avoid extra DB query, clarify error behavior, fix index order - Replace matching_iocs.count() with in-loop counter (both feeds) - Clarify docstrings: stale tags preserved on API errors by design - Swap Tag index from (ioc, source) to (source, ioc) to match dominant filter(source=...) query pattern - Rebase on develop: renumber migration to 0038 --- docker/env_file_template | 7 +- greedybear/cronjobs/abuseipdb_feed.py | 141 +++++++++++ greedybear/cronjobs/repositories/__init__.py | 1 + greedybear/cronjobs/repositories/tag.py | 89 +++++++ greedybear/cronjobs/schedules.py | 12 + greedybear/cronjobs/threatfox_feed.py | 193 +++++++++++++++ greedybear/migrations/0038_add_tag_model.py | 29 +++ greedybear/models.py | 18 ++ greedybear/settings.py | 1 + greedybear/tasks.py | 12 + tests/test_abuseipdb.py | 225 +++++++++++++++++ tests/test_cowrie_extraction.py | 3 + tests/test_models.py | 28 ++- tests/test_tag_repository.py | 96 ++++++++ tests/test_threatfox.py | 240 +++++++++++++++++++ 15 files changed, 1093 insertions(+), 2 deletions(-) create mode 100644 greedybear/cronjobs/abuseipdb_feed.py create mode 100644 greedybear/cronjobs/repositories/tag.py create mode 100644 greedybear/cronjobs/threatfox_feed.py create mode 100644 greedybear/migrations/0038_add_tag_model.py create mode 100644 tests/test_abuseipdb.py create mode 100644 tests/test_tag_repository.py create mode 100644 tests/test_threatfox.py diff --git a/docker/env_file_template b/docker/env_file_template index b8363a06..2640978a 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -67,9 +67,14 @@ COWRIE_SESSION_RETENTION = 365 COMMAND_SEQUENCE_RETENTION = 365 # ThreatFox API key. -# Once added, your payload request domains will be submitted to ThreatFox +# Once added, your payload request domains will be submitted to ThreatFox. +# Also used to download ThreatFox indicators for enrichment. THREATFOX_API_KEY = +# AbuseIPDB API key for downloading the blocklist feed +# Get your free API key from https://www.abuseipdb.com/ +ABUSEIPDB_API_KEY = + # Optional feed license URL to include in API responses # If not set, no license information will be included in feeds # Example: https://github.com/honeynet/GreedyBear/blob/main/FEEDS_LICENSE.md diff --git a/greedybear/cronjobs/abuseipdb_feed.py b/greedybear/cronjobs/abuseipdb_feed.py new file mode 100644 index 00000000..26905eb0 --- /dev/null +++ b/greedybear/cronjobs/abuseipdb_feed.py @@ -0,0 +1,141 @@ +import requests +from django.conf import settings + +from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.extraction.utils import is_valid_ipv4 +from greedybear.cronjobs.repositories.tag import TagRepository +from greedybear.models import IOC + +SOURCE_NAME = "abuseipdb" + + +class AbuseIPDBCron(Cronjob): + """ + Fetch AbuseIPDB blocklist and directly enrich matching IOCs with tags. + + Downloads the AbuseIPDB blocklist (top 10k malicious IPs), joins them + directly against the IOC table, and writes Tag entries for any matches. + Tags are only replaced on successful API responses; on errors, existing + tags are preserved to avoid losing enrichment data. + """ + + MAX_ENTRIES = 10000 # Hard limit as per free API tier + + def __init__(self, tag_repo=None): + """ + Initialize the AbuseIPDB cronjob. + + Args: + tag_repo: Optional TagRepository instance for testing. + """ + super().__init__() + self.tag_repo = tag_repo if tag_repo is not None else TagRepository() + + def run(self) -> None: + """ + Fetch AbuseIPDB blocklist, match against our IOC table, and create tags. + + 1. Download the blocklist from AbuseIPDB /blacklist endpoint + 2. Validate IP addresses + 3. Query our IOC table for matching IPs (WHERE name IN ...) + 4. Replace all AbuseIPDB tags with fresh data + + On API errors, existing tags are intentionally preserved so + enrichment data is not lost due to transient failures. + """ + api_key = settings.ABUSEIPDB_API_KEY + + if not api_key: + self.log.warning("AbuseIPDB API key not configured. Skipping enrichment.") + return + + try: + self.log.info("Starting AbuseIPDB blocklist download for enrichment") + + # Fetch blocklist from AbuseIPDB + url = "https://api.abuseipdb.com/api/v2/blacklist" + headers = {"Key": api_key, "Accept": "application/json"} + params = { + "confidenceMinimum": 75, # Only IPs with confidence >= 75% + "limit": self.MAX_ENTRIES, # Maximum 10k entries + } + + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + + json_data = response.json() + blocklist_data = json_data.get("data", []) + + self.log.info(f"Retrieved {len(blocklist_data)} IPs from AbuseIPDB blocklist") + + # Parse feed into a dict keyed by IP + feed_by_ip = self._parse_feed(blocklist_data) + self.log.info(f"Parsed {len(feed_by_ip)} valid IPs from AbuseIPDB feed") + + if not feed_by_ip: + # No valid IPs found — clear stale tags and return + self.tag_repo.replace_tags_for_source(SOURCE_NAME, []) + return + + # Join against IOC table: find IOCs whose name matches feed IPs + matching_iocs = IOC.objects.filter(name__in=feed_by_ip.keys()).values_list("id", "name") + + # Build tag entries for matching IOCs + tag_entries = [] + matched_count = 0 + for ioc_id, ioc_name in matching_iocs: + matched_count += 1 + enrichment = feed_by_ip[ioc_name] + + if enrichment.get("abuse_confidence_score") is not None: + tag_entries.append( + { + "ioc_id": ioc_id, + "key": "confidence_of_abuse", + "value": f"{enrichment['abuse_confidence_score']}%", + } + ) + if enrichment.get("country_code"): + tag_entries.append( + { + "ioc_id": ioc_id, + "key": "country_code", + "value": enrichment["country_code"], + } + ) + + # Replace all AbuseIPDB tags atomically + created_count = self.tag_repo.replace_tags_for_source(SOURCE_NAME, tag_entries) + self.log.info(f"AbuseIPDB enrichment completed. Matched {matched_count} IOCs, created {created_count} tags.") + + except requests.RequestException as e: + self.log.error(f"Failed to fetch AbuseIPDB blocklist: {e}") + raise + + def _parse_feed(self, blocklist_data: list) -> dict[str, dict]: + """ + Parse AbuseIPDB blocklist data into a dict keyed by validated IP address. + + Args: + blocklist_data: Raw blocklist data from AbuseIPDB API. + + Returns: + Dict mapping IP address -> enrichment dict. + """ + feed_by_ip: dict[str, dict] = {} + + for entry in blocklist_data: + ip_addr = entry.get("ipAddress") + if not ip_addr: + continue + + is_valid, validated_ip = is_valid_ipv4(ip_addr) + if not is_valid: + continue + + feed_by_ip[validated_ip] = { + "abuse_confidence_score": entry.get("abuseConfidenceScore"), + "country_code": entry.get("countryCode", ""), + } + + return feed_by_ip diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py index 84df974e..9773eb33 100644 --- a/greedybear/cronjobs/repositories/__init__.py +++ b/greedybear/cronjobs/repositories/__init__.py @@ -4,4 +4,5 @@ from greedybear.cronjobs.repositories.ioc import * from greedybear.cronjobs.repositories.mass_scanner import * from greedybear.cronjobs.repositories.sensor import * +from greedybear.cronjobs.repositories.tag import * from greedybear.cronjobs.repositories.tor import * diff --git a/greedybear/cronjobs/repositories/tag.py b/greedybear/cronjobs/repositories/tag.py new file mode 100644 index 00000000..08b0abbc --- /dev/null +++ b/greedybear/cronjobs/repositories/tag.py @@ -0,0 +1,89 @@ +import logging + +from django.db import transaction + +from greedybear.models import Tag + + +class TagRepository: + """Repository for data access to Tag entries.""" + + def __init__(self): + self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def replace_tags_for_source(self, source: str, tag_entries: list[dict]) -> int: + """ + Replace all tags for a given source with new ones. + + This is the core operation for the "fetch and join directly" approach: + 1. Delete all existing tags from this source + 2. Bulk-create new tags from the current feed data + + Wrapped in a transaction to ensure atomicity — API consumers never + see incomplete tag data during the replacement. + + Args: + source: Source name (e.g., "threatfox", "abuseipdb"). + tag_entries: List of dicts with keys: ioc_id, key, value. + + Returns: + Number of tags created. + """ + with transaction.atomic(): + self.delete_tags_by_source(source) + + if not tag_entries: + return 0 + + # Bulk-create new tags + tags_to_create = [ + Tag( + ioc_id=entry["ioc_id"], + key=entry["key"], + value=entry["value"], + source=source, + ) + for entry in tag_entries + ] + + Tag.objects.bulk_create(tags_to_create, batch_size=1000) + self.log.info(f"Created {len(tags_to_create)} tags from source '{source}'") + return len(tags_to_create) + + def get_tags_by_ioc(self, ioc): + """ + Get all tags for a specific IOC. + + Args: + ioc: IOC instance to get tags for. + + Returns: + QuerySet of Tag objects. + """ + return Tag.objects.filter(ioc=ioc) + + def get_tags_by_source(self, source: str): + """ + Get all tags from a specific source. + + Args: + source: Source name (e.g., "threatfox", "abuseipdb"). + + Returns: + QuerySet of Tag objects. + """ + return Tag.objects.filter(source=source) + + def delete_tags_by_source(self, source: str) -> int: + """ + Delete all tags from a specific source. + + Args: + source: Source name (e.g., "threatfox", "abuseipdb"). + + Returns: + Number of tags deleted. + """ + deleted_count, _ = Tag.objects.filter(source=source).delete() + self.log.debug(f"Deleted {deleted_count} tags from source '{source}'") + return deleted_count diff --git a/greedybear/cronjobs/schedules.py b/greedybear/cronjobs/schedules.py index cf14faf3..32ebd5e3 100644 --- a/greedybear/cronjobs/schedules.py +++ b/greedybear/cronjobs/schedules.py @@ -71,6 +71,18 @@ def setup_schedules(): "func": "greedybear.tasks.get_tor_exit_nodes", "cron": "7 1 * * 0", }, + # 11. ThreatFox Enrichment: Weekly (Sunday) at 01:07 + { + "name": "enrich_threatfox", + "func": "greedybear.tasks.enrich_threatfox", + "cron": "7 1 * * 0", + }, + # 12. AbuseIPDB Enrichment: Weekly (Sunday) at 01:07 + { + "name": "enrich_abuseipdb", + "func": "greedybear.tasks.enrich_abuseipdb", + "cron": "7 1 * * 0", + }, ] # create or update schedules diff --git a/greedybear/cronjobs/threatfox_feed.py b/greedybear/cronjobs/threatfox_feed.py new file mode 100644 index 00000000..8a7132ff --- /dev/null +++ b/greedybear/cronjobs/threatfox_feed.py @@ -0,0 +1,193 @@ +import re +from ipaddress import ip_address + +import requests +from django.conf import settings + +from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.extraction.utils import is_valid_ipv4 +from greedybear.cronjobs.repositories.tag import TagRepository +from greedybear.models import IOC + +SOURCE_NAME = "threatfox" + + +class ThreatFoxCron(Cronjob): + """ + Fetch ThreatFox IOC data and directly enrich matching IOCs with tags. + + Downloads IP-based IOCs from ThreatFox, joins them directly against + the IOC table, and writes Tag entries for any matches. Tags are only + replaced on successful API responses; on errors or non-OK status, + existing tags are preserved to avoid losing enrichment data. + """ + + def __init__(self, tag_repo=None): + """ + Initialize the ThreatFox cronjob. + + Args: + tag_repo: Optional TagRepository instance for testing. + """ + super().__init__() + self.tag_repo = tag_repo if tag_repo is not None else TagRepository() + + def run(self) -> None: + """ + Fetch ThreatFox IOCs, match against our IOC table, and create tags. + + 1. Download recent IOCs from ThreatFox API (last 7 days) + 2. Extract and validate IP addresses + 3. Query our IOC table for matching IPs (WHERE name IN ...) + 4. Replace all ThreatFox tags with fresh data + + On API errors or non-OK query_status, existing tags are intentionally + preserved so enrichment data is not lost due to transient failures. + """ + api_key = settings.THREATFOX_API_KEY + if not api_key: + self.log.warning("ThreatFox API key not configured. Skipping enrichment.") + return + + try: + self.log.info("Starting ThreatFox feed download for enrichment") + + # Fetch recent IOCs from ThreatFox (last 7 days) + url = "https://threatfox-api.abuse.ch/api/v1/" + headers = { + "Content-Type": "application/json", + "Auth-Key": api_key, + } + data = {"query": "get_iocs", "days": 7} + + response = requests.post(url, json=data, headers=headers, timeout=30) + response.raise_for_status() + + json_data = response.json() + + if json_data.get("query_status") != "ok": + self.log.warning(f"ThreatFox API returned non-OK status: {json_data.get('query_status')}") + return + + iocs_data = json_data.get("data", []) + self.log.info(f"Retrieved {len(iocs_data)} IOCs from ThreatFox") + + # Parse feed into a dict keyed by IP: list of enrichment data + feed_by_ip = self._parse_feed(iocs_data) + self.log.info(f"Parsed {len(feed_by_ip)} unique IPs from ThreatFox feed") + + if not feed_by_ip: + # No valid IPs found — clear stale tags and return + self.tag_repo.replace_tags_for_source(SOURCE_NAME, []) + return + + # Join against IOC table: find IOCs whose name matches feed IPs + matching_iocs = IOC.objects.filter(name__in=feed_by_ip.keys()).values_list("id", "name") + + # Build tag entries for matching IOCs + tag_entries = [] + matched_count = 0 + for ioc_id, ioc_name in matching_iocs: + matched_count += 1 + for enrichment in feed_by_ip[ioc_name]: + if enrichment.get("malware_printable"): + tag_entries.append( + { + "ioc_id": ioc_id, + "key": "malware", + "value": enrichment["malware_printable"], + } + ) + if enrichment.get("threat_type"): + tag_entries.append( + { + "ioc_id": ioc_id, + "key": "threat_type", + "value": enrichment["threat_type"], + } + ) + if enrichment.get("confidence_level") is not None: + tag_entries.append( + { + "ioc_id": ioc_id, + "key": "confidence_level", + "value": str(enrichment["confidence_level"]), + } + ) + + # Replace all ThreatFox tags atomically + created_count = self.tag_repo.replace_tags_for_source(SOURCE_NAME, tag_entries) + self.log.info(f"ThreatFox enrichment completed. Matched {matched_count} IOCs, created {created_count} tags.") + + except requests.RequestException as e: + self.log.error(f"Failed to fetch ThreatFox feed: {e}") + raise + + def _parse_feed(self, iocs_data: list) -> dict[str, list[dict]]: + """ + Parse ThreatFox IOC data into a dict keyed by validated IP address. + + Args: + iocs_data: Raw IOC data from ThreatFox API. + + Returns: + Dict mapping IP address -> list of enrichment dicts. + """ + feed_by_ip: dict[str, list[dict]] = {} + + for ioc_data in iocs_data: + ioc_value = ioc_data.get("ioc", "") + ioc_type = ioc_data.get("ioc_type", "") + + # Extract IP address based on IOC type + ip_addr = self._extract_ip(ioc_value, ioc_type) + if not ip_addr: + continue + + # Validate the IP + is_valid, validated_ip = is_valid_ipv4(ip_addr) + if not is_valid: + continue + + # Check if IP is global (not private, loopback, etc.) + try: + parsed_ip = ip_address(validated_ip) + if parsed_ip.is_loopback or parsed_ip.is_private or parsed_ip.is_multicast or parsed_ip.is_link_local or parsed_ip.is_reserved: + self.log.debug(f"Skipping non-global IP: {validated_ip}") + continue + except ValueError: + continue + + enrichment = { + "malware_printable": ioc_data.get("malware_printable", ""), + "threat_type": ioc_data.get("threat_type", ""), + "confidence_level": ioc_data.get("confidence_level"), + } + + if validated_ip not in feed_by_ip: + feed_by_ip[validated_ip] = [] + feed_by_ip[validated_ip].append(enrichment) + + return feed_by_ip + + @staticmethod + def _extract_ip(ioc_value: str, ioc_type: str) -> str | None: + """ + Extract IP address from various ThreatFox IOC formats. + + Args: + ioc_value: The raw IOC value string. + ioc_type: The IOC type (e.g., "ip:port", "url"). + + Returns: + Extracted IP string or None. + """ + if ioc_type == "ip:port": + return ioc_value.split(":")[0] if ":" in ioc_value else None + elif ioc_type in ("ip", "ipv4"): + return ioc_value + elif ioc_type == "url": + ip_pattern = r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b" + match = re.search(ip_pattern, ioc_value) + return match.group(1) if match else None + return None diff --git a/greedybear/migrations/0038_add_tag_model.py b/greedybear/migrations/0038_add_tag_model.py new file mode 100644 index 00000000..a58587e7 --- /dev/null +++ b/greedybear/migrations/0038_add_tag_model.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.11 on 2026-02-26 11:13 + +import django.db.models.deletion +import django.db.models.functions.datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0037_alter_commandsequence_first_seen_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=128)), + ('value', models.CharField(max_length=256)), + ('source', models.CharField(max_length=64)), + ('added', models.DateTimeField(db_default=django.db.models.functions.datetime.Now())), + ('ioc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='greedybear.ioc')), + ], + options={ + 'indexes': [models.Index(fields=['source', 'ioc'], name='greedybear__source_72b458_idx')], + }, + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 8b1dfbc0..4bbae7d8 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -174,3 +174,21 @@ class Meta: def __str__(self): return self.domain + + +class Tag(models.Model): + """Tags for IOCs from enrichment sources like ThreatFox and AbuseIPDB.""" + + ioc = models.ForeignKey(IOC, on_delete=models.CASCADE, related_name="tags") + key = models.CharField(max_length=128) + value = models.CharField(max_length=256) + source = models.CharField(max_length=64) # e.g., "threatfox", "abuseipdb" + added = models.DateTimeField(db_default=Now()) + + class Meta: + indexes = [ + models.Index(fields=["source", "ioc"]), + ] + + def __str__(self): + return f"{self.ioc.name} - {self.key}: {self.value} ({self.source})" diff --git a/greedybear/settings.py b/greedybear/settings.py index fcf1bdde..33e902bc 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -446,6 +446,7 @@ COMMAND_SEQUENCE_RETENTION = int(os.environ.get("COMMAND_SEQUENCE_RETENTION", "365")) THREATFOX_API_KEY = os.environ.get("THREATFOX_API_KEY", "") +ABUSEIPDB_API_KEY = os.environ.get("ABUSEIPDB_API_KEY", "") # Optional feed license URL to include in API responses # If not set, no license information will be included in feeds diff --git a/greedybear/tasks.py b/greedybear/tasks.py index 73840cd2..a6c68f0b 100644 --- a/greedybear/tasks.py +++ b/greedybear/tasks.py @@ -81,3 +81,15 @@ def get_tor_exit_nodes(): from greedybear.cronjobs.tor_exit_nodes import TorExitNodesCron TorExitNodesCron().execute() + + +def enrich_threatfox(): + from greedybear.cronjobs.threatfox_feed import ThreatFoxCron + + ThreatFoxCron().execute() + + +def enrich_abuseipdb(): + from greedybear.cronjobs.abuseipdb_feed import AbuseIPDBCron + + AbuseIPDBCron().execute() diff --git a/tests/test_abuseipdb.py b/tests/test_abuseipdb.py new file mode 100644 index 00000000..2f1b604e --- /dev/null +++ b/tests/test_abuseipdb.py @@ -0,0 +1,225 @@ +from unittest.mock import Mock, patch + +import requests + +from greedybear.cronjobs.abuseipdb_feed import AbuseIPDBCron +from greedybear.cronjobs.repositories.tag import TagRepository +from greedybear.models import Tag +from tests import CustomTestCase + + +class TestAbuseIPDBCron(CustomTestCase): + """Tests for AbuseIPDBCron with the 'fetch and join directly' approach.""" + + def setUp(self): + self.tag_repo = TagRepository() + self.cron = AbuseIPDBCron(tag_repo=self.tag_repo) + + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_skips_when_no_api_key(self, mock_settings): + """Should skip enrichment when ABUSEIPDB_API_KEY is not set.""" + mock_settings.ABUSEIPDB_API_KEY = "" + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 0) + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_enriches_matching_iocs(self, mock_settings, mock_get): + """Should create tags for IOCs that match blocklist IPs.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "ipAddress": self.ioc.name, + "abuseConfidenceScore": 84, + "countryCode": "CN", + } + ], + } + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + self.cron.run() + + tags = Tag.objects.filter(source="abuseipdb", ioc=self.ioc) + self.assertTrue(tags.exists()) + + tag_keys = set(tags.values_list("key", flat=True)) + self.assertIn("confidence_of_abuse", tag_keys) + self.assertIn("country_code", tag_keys) + + confidence_tag = tags.get(key="confidence_of_abuse") + self.assertEqual(confidence_tag.value, "84%") + + country_tag = tags.get(key="country_code") + self.assertEqual(country_tag.value, "CN") + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_no_tags_for_non_matching_iocs(self, mock_settings, mock_get): + """Should not create tags for IPs not in our IOC table.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "ipAddress": "203.0.113.99", + "abuseConfidenceScore": 90, + "countryCode": "RU", + } + ], + } + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 0) + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_replaces_stale_tags(self, mock_settings, mock_get): + """Tags should be replaced on each run, not accumulated.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + + # Create a pre-existing tag with old confidence + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="50%", source="abuseipdb") + + # New run with updated confidence + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "ipAddress": self.ioc.name, + "abuseConfidenceScore": 95, + "countryCode": "CN", + } + ], + } + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + self.cron.run() + + # Old tag should be gone, new one present + confidence_tags = Tag.objects.filter(source="abuseipdb", ioc=self.ioc, key="confidence_of_abuse") + self.assertEqual(confidence_tags.count(), 1) + self.assertEqual(confidence_tags.first().value, "95%") + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_clears_tags_when_ip_delisted(self, mock_settings, mock_get): + """Tags should be removed when an IP is no longer in the blocklist.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + + # Pre-existing tag + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + + # New run with empty blocklist (IP delisted) + mock_response = Mock() + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="abuseipdb", ioc=self.ioc).count(), 0) + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_handles_request_exception(self, mock_settings, mock_get): + """Should raise on network errors.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + mock_get.side_effect = requests.RequestException("Connection error") + + with self.assertRaises(requests.RequestException): + self.cron.run() + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_skips_invalid_ips(self, mock_settings, mock_get): + """Should skip entries with invalid IP addresses.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "ipAddress": "999.999.999.999", + "abuseConfidenceScore": 90, + "countryCode": "RU", + }, + { + "ipAddress": "", + "abuseConfidenceScore": 80, + "countryCode": "US", + }, + ], + } + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 0) + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_does_not_affect_threatfox_tags(self, mock_settings, mock_get): + """AbuseIPDB enrichment should not touch tags from other sources.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + + # Create a ThreatFox tag + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + + mock_response = Mock() + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + self.cron.run() + + # ThreatFox tag should still exist + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 1) + + @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.settings") + def test_enriches_multiple_iocs(self, mock_settings, mock_get): + """Should enrich multiple IOCs from a single feed download.""" + mock_settings.ABUSEIPDB_API_KEY = "test_key" + + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "ipAddress": self.ioc.name, + "abuseConfidenceScore": 84, + "countryCode": "CN", + }, + { + "ipAddress": self.ioc_2.name, + "abuseConfidenceScore": 92, + "countryCode": "RU", + }, + ], + } + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + self.cron.run() + + # Both IOCs should have tags + self.assertTrue(Tag.objects.filter(source="abuseipdb", ioc=self.ioc).exists()) + self.assertTrue(Tag.objects.filter(source="abuseipdb", ioc=self.ioc_2).exists()) + + # Verify correct values + ioc1_confidence = Tag.objects.get(source="abuseipdb", ioc=self.ioc, key="confidence_of_abuse") + self.assertEqual(ioc1_confidence.value, "84%") + + ioc2_confidence = Tag.objects.get(source="abuseipdb", ioc=self.ioc_2, key="confidence_of_abuse") + self.assertEqual(ioc2_confidence.value, "92%") diff --git a/tests/test_cowrie_extraction.py b/tests/test_cowrie_extraction.py index 020dd51e..2f78633e 100644 --- a/tests/test_cowrie_extraction.py +++ b/tests/test_cowrie_extraction.py @@ -179,6 +179,7 @@ def test_get_url_downloads(self): self.mock_ioc_repo.get_ioc_by_name.side_effect = [scanner_mock, payload_mock] mock_payload_record = Mock() + mock_payload_record.general_honeypot.all.return_value = [] self.strategy.ioc_processor.add_ioc.return_value = mock_payload_record self.strategy._get_url_downloads(hits) @@ -332,10 +333,12 @@ def test_deduplicate_command_sequence_existing(self): def test_extract_from_hits_integration(self, mock_iocs_from_hits): """Test the main extract_from_hits coordination.""" mock_ioc = Mock(name="1.2.3.4") + mock_ioc.related_urls = [] # Return list of IOCs as expected by the new format mock_iocs_from_hits.return_value = [mock_ioc] mock_ioc_record = Mock() + mock_ioc_record.payload_request = False self.strategy.ioc_processor.add_ioc.return_value = mock_ioc_record hits = [{"src_ip": "1.2.3.4", "session": "s1", "eventid": "cowrie.session.connect"}] diff --git a/tests/test_models.py b/tests/test_models.py index 89d7124d..2fa4a212 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,4 @@ -from greedybear.models import IocType, Statistics, ViewType +from greedybear.models import IocType, Statistics, Tag, ViewType from . import CustomTestCase @@ -62,3 +62,29 @@ def test_statistics_model(self): def test_general_honeypot_model(self): self.assertEqual(self.heralding.name, "Heralding") self.assertEqual(self.heralding.active, True) + + def test_tag_model(self): + tag = Tag.objects.create( + ioc=self.ioc, + key="malware", + value="Mirai", + source="threatfox", + ) + self.assertEqual(tag.key, "malware") + self.assertEqual(tag.value, "Mirai") + self.assertEqual(tag.source, "threatfox") + self.assertEqual(tag.ioc, self.ioc) + self.assertEqual(str(tag), "140.246.171.141 - malware: Mirai (threatfox)") + + def test_tag_cascade_delete(self): + """Tags should be deleted when their IOC is deleted.""" + from greedybear.models import IOC, IocType + + temp_ioc = IOC.objects.create(name="10.20.30.40", type=IocType.IP.value) + Tag.objects.create(ioc=temp_ioc, key="test", value="test_val", source="test_source") + + self.assertEqual(Tag.objects.filter(ioc=temp_ioc).count(), 1) + + temp_ioc.delete() + + self.assertEqual(Tag.objects.filter(ioc_id=temp_ioc.id).count(), 0) diff --git a/tests/test_tag_repository.py b/tests/test_tag_repository.py new file mode 100644 index 00000000..0a33a22f --- /dev/null +++ b/tests/test_tag_repository.py @@ -0,0 +1,96 @@ +from greedybear.cronjobs.repositories.tag import TagRepository +from greedybear.models import Tag +from tests import CustomTestCase + + +class TestTagRepository(CustomTestCase): + """Tests for TagRepository.""" + + def setUp(self): + self.repo = TagRepository() + + def test_replace_tags_for_source_creates_tags(self): + """Should create new tags for a source.""" + tag_entries = [ + {"ioc_id": self.ioc.id, "key": "malware", "value": "Mirai"}, + {"ioc_id": self.ioc.id, "key": "threat_type", "value": "botnet_cc"}, + ] + + count = self.repo.replace_tags_for_source("threatfox", tag_entries) + + self.assertEqual(count, 2) + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 2) + + def test_replace_tags_for_source_replaces_existing(self): + """Should delete existing tags and create new ones.""" + Tag.objects.create(ioc=self.ioc, key="malware", value="OldMalware", source="threatfox") + + tag_entries = [ + {"ioc_id": self.ioc.id, "key": "malware", "value": "NewMalware"}, + ] + + count = self.repo.replace_tags_for_source("threatfox", tag_entries) + + self.assertEqual(count, 1) + tags = Tag.objects.filter(source="threatfox") + self.assertEqual(tags.count(), 1) + self.assertEqual(tags.first().value, "NewMalware") + + def test_replace_tags_preserves_other_sources(self): + """Should not affect tags from other sources.""" + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + + self.repo.replace_tags_for_source("threatfox", []) + + self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 1) + + def test_replace_tags_with_empty_list_clears_source(self): + """Should delete all tags for a source when given empty list.""" + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + Tag.objects.create(ioc=self.ioc, key="threat_type", value="botnet_cc", source="threatfox") + + count = self.repo.replace_tags_for_source("threatfox", []) + + self.assertEqual(count, 0) + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) + + def test_get_tags_by_ioc(self): + """Should return all tags for a specific IOC.""" + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + Tag.objects.create(ioc=self.ioc_2, key="malware", value="Emotet", source="threatfox") + + tags = self.repo.get_tags_by_ioc(self.ioc) + + self.assertEqual(tags.count(), 2) + + def test_get_tags_by_source(self): + """Should return all tags from a specific source.""" + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + Tag.objects.create(ioc=self.ioc_2, key="malware", value="Emotet", source="threatfox") + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + + tags = self.repo.get_tags_by_source("threatfox") + + self.assertEqual(tags.count(), 2) + + def test_delete_tags_by_source(self): + """Should delete all tags from a specific source.""" + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + + deleted = self.repo.delete_tags_by_source("threatfox") + + self.assertEqual(deleted, 1) + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) + self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 1) + + def test_tags_deleted_when_ioc_deleted(self): + """Tags should be cascade deleted when their IOC is deleted.""" + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + + ioc_id = self.ioc.id + self.ioc.delete() + + self.assertEqual(Tag.objects.filter(ioc_id=ioc_id).count(), 0) diff --git a/tests/test_threatfox.py b/tests/test_threatfox.py new file mode 100644 index 00000000..13b290ca --- /dev/null +++ b/tests/test_threatfox.py @@ -0,0 +1,240 @@ +from unittest.mock import Mock, patch + +import requests + +from greedybear.cronjobs.repositories.tag import TagRepository +from greedybear.cronjobs.threatfox_feed import ThreatFoxCron +from greedybear.models import Tag +from tests import CustomTestCase + + +class TestThreatFoxCron(CustomTestCase): + """Tests for ThreatFoxCron with the 'fetch and join directly' approach.""" + + def setUp(self): + self.tag_repo = TagRepository() + self.cron = ThreatFoxCron(tag_repo=self.tag_repo) + + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_skips_when_no_api_key(self, mock_settings): + """Should skip enrichment when THREATFOX_API_KEY is not set.""" + mock_settings.THREATFOX_API_KEY = "" + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_enriches_matching_iocs(self, mock_settings, mock_post): + """Should create tags for IOCs that match feed IPs.""" + mock_settings.THREATFOX_API_KEY = "test_key" + + # Mock ThreatFox API response with our test IOC IP + mock_response = Mock() + mock_response.json.return_value = { + "query_status": "ok", + "data": [ + { + "ioc": f"{self.ioc.name}:4444", + "ioc_type": "ip:port", + "malware": "win.mirai", + "malware_printable": "Mirai", + "threat_type": "botnet_cc", + "confidence_level": 85, + } + ], + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + self.cron.run() + + tags = Tag.objects.filter(source="threatfox", ioc=self.ioc) + self.assertTrue(tags.exists()) + + tag_keys = set(tags.values_list("key", flat=True)) + self.assertIn("malware", tag_keys) + self.assertIn("threat_type", tag_keys) + self.assertIn("confidence_level", tag_keys) + + malware_tag = tags.get(key="malware") + self.assertEqual(malware_tag.value, "Mirai") + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_no_tags_for_non_matching_iocs(self, mock_settings, mock_post): + """Should not create tags for IPs not in our IOC table.""" + mock_settings.THREATFOX_API_KEY = "test_key" + + mock_response = Mock() + mock_response.json.return_value = { + "query_status": "ok", + "data": [ + { + "ioc": "203.0.113.99:4444", + "ioc_type": "ip:port", + "malware_printable": "Emotet", + "threat_type": "payload_delivery", + "confidence_level": 90, + } + ], + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_replaces_stale_tags(self, mock_settings, mock_post): + """Tags should be replaced on each run, not accumulated.""" + mock_settings.THREATFOX_API_KEY = "test_key" + + # Create a pre-existing tag + Tag.objects.create(ioc=self.ioc, key="malware", value="OldMalware", source="threatfox") + + # New run with updated data + mock_response = Mock() + mock_response.json.return_value = { + "query_status": "ok", + "data": [ + { + "ioc": f"{self.ioc.name}:4444", + "ioc_type": "ip:port", + "malware_printable": "NewMalware", + "threat_type": "botnet_cc", + "confidence_level": 95, + } + ], + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + self.cron.run() + + # Old tag should be gone, new one present + tags = Tag.objects.filter(source="threatfox", ioc=self.ioc) + malware_tags = tags.filter(key="malware") + self.assertEqual(malware_tags.count(), 1) + self.assertEqual(malware_tags.first().value, "NewMalware") + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_clears_tags_when_ip_delisted(self, mock_settings, mock_post): + """Tags should be removed when an IP is no longer in the feed.""" + mock_settings.THREATFOX_API_KEY = "test_key" + + # Pre-existing tag + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + + # New run with empty feed (IP was delisted) + mock_response = Mock() + mock_response.json.return_value = { + "query_status": "ok", + "data": [], + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + self.cron.run() + + # Tags should be gone + self.assertEqual(Tag.objects.filter(source="threatfox", ioc=self.ioc).count(), 0) + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_skips_private_ips(self, mock_settings, mock_post): + """Should filter out private, loopback, and reserved IPs.""" + mock_settings.THREATFOX_API_KEY = "test_key" + + mock_response = Mock() + mock_response.json.return_value = { + "query_status": "ok", + "data": [ + { + "ioc": "192.168.1.1:4444", + "ioc_type": "ip:port", + "malware_printable": "TestMalware", + "threat_type": "botnet_cc", + "confidence_level": 80, + }, + { + "ioc": "127.0.0.1:4444", + "ioc_type": "ip:port", + "malware_printable": "TestMalware2", + "threat_type": "botnet_cc", + "confidence_level": 80, + }, + ], + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_handles_non_ok_status(self, mock_settings, mock_post): + """Should handle non-OK API response gracefully.""" + mock_settings.THREATFOX_API_KEY = "test_key" + + mock_response = Mock() + mock_response.json.return_value = { + "query_status": "no_result", + "data": [], + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + self.cron.run() + + self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_handles_request_exception(self, mock_settings, mock_post): + """Should raise on network errors.""" + mock_settings.THREATFOX_API_KEY = "test_key" + mock_post.side_effect = requests.RequestException("Connection error") + + with self.assertRaises(requests.RequestException): + self.cron.run() + + def test_extract_ip_from_ip_port(self): + """Should extract IP from ip:port format.""" + ip = ThreatFoxCron._extract_ip("1.2.3.4:4444", "ip:port") + self.assertEqual(ip, "1.2.3.4") + + def test_extract_ip_from_url(self): + """Should extract IP from URL format.""" + ip = ThreatFoxCron._extract_ip("http://1.2.3.4/malware.exe", "url") + self.assertEqual(ip, "1.2.3.4") + + def test_extract_ip_returns_none_for_domain(self): + """Should return None for domain IOC types.""" + ip = ThreatFoxCron._extract_ip("evil.example.com", "domain") + self.assertIsNone(ip) + + @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.settings") + def test_does_not_affect_abuseipdb_tags(self, mock_settings, mock_post): + """ThreatFox enrichment should not touch tags from other sources.""" + mock_settings.THREATFOX_API_KEY = "test_key" + + # Create an AbuseIPDB tag + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + + mock_response = Mock() + mock_response.json.return_value = {"query_status": "ok", "data": []} + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + self.cron.run() + + # AbuseIPDB tag should still exist + self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 1) From 536a38ae180128989e2f03f2a2ef9c761cb9a5a0 Mon Sep 17 00:00:00 2001 From: SANCHIT KUMAR Date: Fri, 27 Feb 2026 02:43:37 +0530 Subject: [PATCH 003/109] Skip IOCs with empty days_seen in scoring pipeline. Closes #886 (#892) Closes #886 Signed-off-by: Sanchit2662 --- greedybear/cronjobs/scoring/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/greedybear/cronjobs/scoring/utils.py b/greedybear/cronjobs/scoring/utils.py index a7f2b383..694bfe23 100644 --- a/greedybear/cronjobs/scoring/utils.py +++ b/greedybear/cronjobs/scoring/utils.py @@ -66,6 +66,8 @@ def get_features(iocs: list[dict], reference_day: str) -> pd.DataFrame: result = [] for ioc in iocs: days_seen_count = len(ioc["days_seen"]) + if not days_seen_count: + continue time_diffs = [date_delta(str(a), str(b)) for a, b in zip(ioc["days_seen"], ioc["days_seen"][1:], strict=False)] active_timespan = sum(time_diffs) + 1 result.append( From 80c45a215c9ff6137ca34c3e2de0a8e5e8a6f771 Mon Sep 17 00:00:00 2001 From: Arnav Vinod Deshpande Date: Fri, 27 Feb 2026 14:02:56 +0530 Subject: [PATCH 004/109] Feeds page: add IntelOwl analysis link for each IOC. Closes #292 (#865) * added intelowl column * url encoding * 3 new testcases * fix: prettier formatting in TableColumns.test.jsx * new image * new --- docker/env_file_template | 7 ++- frontend/public/intelowl.png | Bin 0 -> 54918 bytes .../src/components/feeds/tableColumns.jsx | 28 +++++++++ frontend/src/constants/environment.js | 4 ++ .../components/feeds/TableColumns.test.jsx | 53 ++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 frontend/public/intelowl.png diff --git a/docker/env_file_template b/docker/env_file_template index 2640978a..a234678e 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -78,4 +78,9 @@ ABUSEIPDB_API_KEY = # Optional feed license URL to include in API responses # If not set, no license information will be included in feeds # Example: https://github.com/honeynet/GreedyBear/blob/main/FEEDS_LICENSE.md -FEEDS_LICENSE= \ No newline at end of file +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 diff --git a/frontend/public/intelowl.png b/frontend/public/intelowl.png new file mode 100644 index 0000000000000000000000000000000000000000..04ebba6a8227cec29e5d0fd1972033cc488f8b47 GIT binary patch literal 54918 zcmZ5{bx<757wxhvuvl2!VUghO!QF#fa7l22yK8WFC%C)2^WnkW2?TeS$M03We_l;b z&D5Q$sqUV>r|&)YOqh~_6e)zhhso~{0B~jj0FDd+0Nzvp0M{0(`yL7fewHHLTL#g1t=0KiQ-{v^8{fIo|ET?(%NC|Gcz({g_J4<($knA z5fm^VEXwe6cG_K2hdUKJ#o2jG==i;UE0_Abx`3+dWJX!l0UHHHcZ>bGYd5m>Qi#p} zYwIfcje>rtt2G&oQUA3Y5jGid{~+9|{ol?1*UC)^GbT}WLQsF<;h`Gx;#(-^6>wlI zOdExgWB059v6CRBKxqpIM=D~t3_2|UfY~3;Ne&;z3P~{Ls_b21vvwzFw1itwQ%VAf zWEy{80c2oo{rA0c%BTOQ?JA%fwLO{jDMYRW2ie?;_IKr6aMPVnL$l3{o_C;)-ugP@ zsenLRVPjMv3+UJ{fCYl&B#afh;};5K2}Y?CH8jP6Nd;g4M*Bg9n&-$2*1EuUVQI)h_dG`hX{4yBkAu9oM!Y08e zfOP-)$Iv%I#GI1l%vW_q<;JL-T+V(TA1!}7TJ9+KG@M;%>gqkKsV|-d8M$JB^r*nj zqXe|U(&Dj5{Q+^5v_Sx^fMA#)vM8Z%ur&ZYa(KYOJ3NCh5C?fscqAwmpNyeF%iK!I zSPPq4ReG1UO1;Q(-{FUgwU>w2$!P1*-pIewxY^<|b_(0Aqq$=|0rse{2KdbK--74d z*8N;P=ciVl2MZou7$~G+lGfPR>8^@$zb~w6_HsTjlKMc4ns9 zaj}*Vz`&8AuwnpJAQd!t5D)-e486UY#n0cLhY}Pd!tmG~e3Yw+AOdZ~89#Fk2@ZwB zBr^=45{`pHp@xz@L9vM1=y;%zfy5yb7Z=k4URFVj7`?O-y;cW{qnR}1{p(_b{+px& zyX`A?d42&{sNQjTjggfZ;nuxNZP&I&NlB<^9zsUb>A#Jx*Q$y3Pb4;*9hH2CCydLY za<&YX$C3W&pCz#)IciIaaAc6QG;*PSNE~$bs89z&%|QU3I4BkvXc!5AUJwaIl(@Go zhxmqbbfjVhA&T$4{lpL+n9u+o%`UX1^aqS|3IZq~qTt+tATGFu8ffSkYtk^YgZs|t z_`aiNb*9gL>2-C-1y)t258`YRda5=YMYX)aloHp^EUuHgM$`+>W9`sr8%Dg9I(%3@ zH0QGoSKIe4zE3WnY)A`KhBYv;Jj*0m)T{D{iUDvCV>}wlz8X3}TtILDc7T6NB?EFQ z;=oW~T5yl3xG)fm6(kDAJ1N$Lw_ctaFCVLbc>n=qEQJ|r_5i9^)Nl}B1+2h#L)lUP ze6S&ktUr7jma%FN0SypK2lO4iMkGSTTs>ffGG0D1EBlM@_Dhl~bq=AB_g@)Z-fJwT zEDvH#OBagLtA(bIZqqMMDW&Ow|`DBG^iG_qLBr1t{%V1gsdf1v(VNY$^%hDp@It$;bn(22C|E7U8sly z>HrK_Aaqb%Ee+c=rlh&1<$^|ezUj`){@#m~3B`fF_vuM_wGZ;a<^#@7nq14v$w^~~ zZ$Cvxr#0-$Pgg>onSkV6gbXy|P4~TvcKCZ@{sIISo3h|agtF58QrUnsJZYLBWdcrg zh!q|%Xk8--tDnttYE;xy7}FfgE)2tfXS{wrLd0G7$*bb}NeizJL|=+P055%&LwVh+ zFwoMS5P=9JDG|^7H4LIjvbB-`VGIMA`O=~z= zEU>K^WnuF1%*8 z6C%4**NLp-!q4NjH|c8Kx_cpDlhH&X@3h2~#>S+9uZn2M011)+5HwQ4Nt1<>Y&+k8SSJ86GPIi2< z4%z88AO_NAF|o{_Ot};N+=0jTk>+Q^p^AyIlkcX8hNbTvB=y#>_w6Rs^sSoT-W^tB z{m>7{Obx$+z{2FgNTr zEwiUw;HC~a{H&)PW=Ip-Ju0d1;^glA`eyUN^j})0pE=U#Q40)37$Q+%S_HqVRW;v* zWerwRG)o2H@z>6`=6zzeABbTYEGcv9a=wCkCMzn}_frDHC$CXC37tcx>_gfIyb-Gf z^za-y<`{_$5`TscQ5jceuYyY#^?m*&4R_xa&oT?VewNo2bnpDOi>jmZzYhHmtgSa@ zG9-p=~l|b-t$1(K3D2KvxYKfM?Nwp#vR*E5x*nweU)EKl!Ci!1;ZK zv1{FGj=YZ;;p{zML8xJQ;0kzO3(k9c#YqR4V4U~xR{s&z9iMGWHOqrQ8xTcR2N&*e zZm=mLvjv>b##Li{Yp-Um4N~2&!i0t!(pEGAjBfR6COstStOKb=% zF^#x!uRR=NdTlG#QuJlq;#jeP_iL-I&|Tp&F6leJ-5vjARyo6Q0hjfl4eZN8pfk}~ z-}W}bz0gG!>5}~--&xD|KJOpcaM%>7Wphfbz(44@NRSx-JsvX8QP@<7<^IxVN|r`C zbFwLB*t8)+A2L9}zW-EIfPs(KqkRR+1Er&qKbdLpFX=#=0_Iv=5oBqdd$DX=`A;;5XIGT@B^8OZ}v)k6Z0RD|IICU9>+pIdG1X3CJmle#--uqx=6N4SBWVTKOS1 za$KgA{<9;NB>xsI%YwqQZ87{r_Up=q_16yLwXr^6NzoyQFy_%;$)KG zJctH(P$g820lMerW;i?%$m1J>Kf*6i2Y`_W;vLgf?4=e8X>=4h~BBJ;H3-STU`$~?``@YaCXUEOjSWoa3Bhf8t{|7RX6Ve6W zckSl)pFfVEIiK*o^gL=g%aiw21WBufCSr@T(#(Zwkx82XaA?^r8EVEqjfD#QhZh}T z#$JtPC%%3X@~H;;@I%<~>y%BHMcn1q&!MVo??qsDFj~1XUa$VW)q%TzUzI!>AofY1 zBREzyuNIK&c}QY@ z6>>AVo0D{TSXhRCs4P3B*?M^Ub>=bU`ffLzZE~+KZhudc;+BqFVh>U#lWu3M48GpM z!- z0A7-C^<*=gk!C|sA7pXsoU3&ifao>oTE~0c;;enfel7e_(Cpsi1xR}Yf0Dk(?(#v` zja2uSr$T0L5|b%?_!^jia%)@Wi}oKC2ip+7@b(ar*XbBij|Z`Hd}O{~%!^obZN%(| ztw<<^A~622@LI82ySuK4sUYBP8Jz=Kpq{WS5s#7yHasgze@-UHT5QXI4{L12gB_OP zgwGwvm3!t-qu4+pw+lzBU%@q%W_D~cjAYC+J$}{M$_o~swCkj4{pqXgl|+?7gf$EY z)Rzkuena4)IuhDx;v!{-v=$9YDnA(&eU4izTz3_8HTUOX;tZ^j;gBZ)x?7 ztrS8SJK z`pUos6b05Iqrz%PB1m_Q;lck1J7?rd{A5}m=-B@{_}jZuiJZ3=kq5;B;16no9omT zHNCSMYpwcq$*tySo8z$cb<)QN#f|Gy(x+c8$9eZTO~KJ)7JlCljrwuy@<8RB7_5F# zWG4>IkmLIBpE`2OU88-TIBB@?{siu~*wj+{{0R)^%uHOLhm59lL9JvE?X5=qmOtcE zoaxi3fKzAhXH=gaPPfpA#HLo0skuD3Pd6P#TuyA24BevQSIwltsYr&4)=>FlUZXXw-ICo+blfQoz9QyrGFWT*o zH)f-=&`1R?I(?4~e){ZT3OR-FhD=h2(;1<5H-NfJKk~^cZ_UYGc!h=iFqPnr zhG7Bpr2faep^S;(@LNxSlUBGP=Fc~+rW#7fBwODyD<bTk>&n=X+!oK0IHKG!57*LlX~44A@VlM>l66GC1ln2%%eg&5I=Tk!rCf5 z;$Y8>>;L+5-qzm7`J%nHfQ=!zs*;~WA@!1Uv|98jgI3~5Ii){Xu;>e@oOL_LVbxG- z`iS1g0^z4Dw5d?HflloYG_fYykY`=q-la8Bat92yjtmCbP8c$@W1qivo+Vf?4~#3e$8U3 z>MfO1l$ve0jS?HbP3L91(9_i?zK#3RoG(}Fo9`6)Hi6VkT;fLOnQK?L@xiOvB}HF@)!Dm*5NieSBv8BfVQ zxdwt3Lh}L#0IDxe3u9_Zw83~4EoJyIpu0$Ov}5tlZdK z5y5Bs4c5v1OcXy*F4h)k3u~_(t35bSgbTC^u|_id1DP|VwO`(XSBz)3-F&j@L(xDn zGT@>HnxN`B8pE2}_hEzlIK;`dm~xGgTX^)u7NfJ;n~4Ryw!+m+_vVKU%v;I`U5Gyd z*+mmqB0irjOre`=SFa1zP2aiz#{9CNZ=c9S(80Q;n5?k=o0i(Hsu2&`Yu2+P(R)We zDqTrf?}Q2GJXBp}e#>UgtwxGz_JGdfWE+|KaXZN|sBe8<7sa@*l*WH7X6WUmLD7k++goF1!2djUH{@oojjs0yRzCZJ|ldRmch$juolXGaehwmjrmZGC^a zC8PP>L5PIjN{LW^{eZpZ1;)doD^U`&K4BkNP&FfBVHX9%vGhqh`;|8mv{e2$9J!=$ z)eQF**biHQlQ+gmT`?c>`QH%bmZOP-@7jg#N<9T%TXSsIU0)~;W^!37WkUmjDhv7w zqXF_emRyxR8YV2q9Rw5zw8lBBkR^`bTe!OlERKXtOGJjqjGNKGLa_z-8b@Ac{8u<= zsG^;ggFREgoJmM)g7jBEsXo+I&I5m>(vGNM6+k`S11Cn~`c%c2=|*$r36d>`UNm>* zp|!<{D8{;&?%Hb)@62f#?H|n7_z*-uzOY814FCf*NxjZZ7KJAG2kZh;-wL%P7-iq2 z;A~fmHU-5W&U$Bbdju#8MS{{#%R}~~gZ~o%wlLJuVHwIJVNs|V8HKGjp9(xg_hup7 z>EjW1M#?dFHpMsh>dfC<3GFaidF^zez3zFFK5ukeJh`HF@N55-W@uZ=jg_YehK7m; z<-zh=F>+u*XefuE*ial;3LvZ~k|;1j9E4RfrK6=~6{|fJ0&P}rT2B6xBG%i+$T`q5 zqgOq2a&``X&ayfaf14@=ITo2#i58NiVTO{y3=EfCaUkhA?xwBAXV2^A&HX4e(%it| zcTqv*(N!Fe1+xw1up^7@4W)zpjzi2~V#5Jq5QoJvm7{C<()JK47+5e?t9U0fu10ch zqy#Mr3@Zym4HtZ^{k%X4;I~qC;}(q{2r023mQu0;PX}%Nu}eT`e)DnAATsdJM?Q|n zBG|fM(vfZBJu&3`-7_SHrh+jaeEV?yIOTU0yjoNxl`zrJyV6S|EVSFb@-CxmB* z??e5B5wQvl3k(VK)xZ#P5y~LI5IhwzT6_b!y)H#YQR7i$a3fXH+)@JpEEQFPHV*4| ziJ7_dg$0-F%CK9fJqv77u55daJ18 zJsTUst>)C*+r~R5XXnU;#9+kb!ZR%vWN3v&kTw}hBmqDJM*@RV0=2&q+y^dHK_sr! z6Y;z+$}RR}IXWSk&RQ2!vpi;Yx_5T0ZNy6CU4bO8RItQjl@!9)8c~Ad;_vzxt1`b{ z5pDz^qP|-GM=i6Y%DL5~Ec_{ZS?56CIwh#om+KLOfq89Dzdq<}-FRwqa!U$GqXHq* zWTc2MZ=3sX?+uS_nj%pCS&K5^lC5mWWplAgz&T@SMhQz)1`H*J*w|c*C@h#2ZrV9Il%MK{ zXU9Kn=~26V-45Eza1{+6W^&%%gcxHoJMAV~eb(=idplpOxvXy)K=8l@1oXZpa76?S zRyf#m<j-PA@Eb^q#jIlozl zw)*`}ZZEju0E`DMz{?Tu_-$tF!l#EQQq02KW#SYSn0QKilQGoKmVWuP=l}_DsNq*Q z23B%L8ah#B{00h<)I`)I`r^~MadWTRtE5uo1ASs4-@ES)`&Y{=Bm2lTrWmGdLu!Q~ zJ5Kl%6zUwUx7=JisNL*s_YJvR>H{Z9<=mIwYzaSNg2tu~dOxTQQ*S-+@N6x(stcAc zX3dr72!Boa@bI`+`Pnd7I~-fQWC@~fG#3f?3#MmJ7y@Kf0ATY1c!G8_VWcsnDxvVS z2ir@OcY7u&_&65OfAQL!7h}B^`0iZj7IX#~n*f>grpg_9{eoT-GHl_F^u`$iB#;Tl z(wt0~JswOAIPsVvWhm@fms}VHsFB+c0-$Y+h^z_-P%tej$JCrulVnzi?0Ou{__vgF zP|U3BhKS3Q)ju*q(?Gm>c+lSUK{zjD*et6IV1*wZlc?yRW#aL=oot+SN|AyQT&%x_ z3#`}c4n!KMF`Bd`LmPqE!MRW;C=IYwjUcoE&;u=mQo+SH=)c7IXE%QJ`>aF{@VS2U zjMAG;&uQt^-m?04``C6+WW)4aVIzt4i(ZX_JhTzQs?hrdO4-GV&Kf9>#EOXJURc%= zo}ElC8=oss`4x_xLbLdT*?cO*&GxXq*>hAhygB(s*(jqcQ{)##C2_pW^^v=+Ze0*f1=tL~}Ap*6-JVd0~sGZY7}FH6p0Fe^9}M)quF| zxRcY$=MupU4ux7rl7DlOfYC-&Y8=(tKN{qgH1IuLrC=6%=27UrL#oQ=jfzWQs@kAj zs+q6(h@7)3Tzv_7^FKMA3vjS_-N5R6TK9CVv6wn5Eit)J=}Ton!U%$@I;w-aa;iD~ zE(4H=Ks7Rfbn#j}MebQvO{_LI=|4xVuSY4J^+F;KTAbns1nje*Zk+sdh2j*Jp2DlQcu{@uiu? z_8dEgF7)^b+3mk(61I3RY#yI9NMCSPWcj|)Y4=<^3MHwqSfG536s8SrWU)u}UodAh zPd(z`g$;D!p{?)?_VymLc%!0P<-1V?WhRp-ZtFRgBZb)r8R4R7pv zo_SoD=tK+MY$e@QdKR@XmQW&&+6Q2PW?+Ii;^h%ocG0yjo$wOOWPU`OapG1agZf`O zkzt4)886At?>{IRD}pYQ+TC>AV=I+9|LWG;0SANk3hCU?KPL1`y7=DSFTuk(Ubp{- zeTO^VUUqqID^(-V-gLrVF7N98Zz<_7tN`AMeZ}>_FQK)r`$K_sohD)$$0c@c_7d7f zD)vci$;A67ugbE|HMeX^Cv`nviWi5c9p-n4#M7x`6ZYz=e$^T+Jal>~UFleo$DQpKWsCof9tsD0El-6sg| zW-=>ZOJNmf*BA-AfS?g+J_sf1vLSa)TSp{ zpTq*2mg)0xb6yv46@6Z(d_Q|Xrkxw^%nfK8^rGr`e|*p8I#iSf!!d7H6oH5A?xx!} zDM1X^pDXS@MIryqk^cqo((q2|AQd2H3I~881%8ydskw}~dHfGB$@1%e*wXg+55E3g z6P)Hkk4n+dNVC0VtJg&yjF4`5()y2{?&V+#u3H$^{v`ejOyGbht4J8!FgBKaWaTMb zgF5vP)7$)Fx5Lx1!3*~F5xZ|=T&=g~|K^U^dP^GF<DyWlvO$R0q&StN3O+%3tG=8T*%mxLf%m@QRU)@nFlc z1V*?1d}2VYPK(r&QPfx%PAM5WL|Y8O5T}zQ99>$}Bl_~5RW0aw#N2(ec`B#)Ca56P z3gclbwi?^u!MAj>4?58Ia(DTDb2(f*Dm9+;`cacuv_V&hw7Yz9_qwzuK^fY1VoPvD?l-M1|XQEWrJ2FxhhXj_PE! zDNJIRa)5{}7)64%spM$z;O&9i=W+-4?O7e_#ofLT`JMZOtM+5T^DQqu(so?V zA!{0tK67QPXqjo2QdlE$FAGC5u!issYSjOnHyr!8wY3$FM;Z8#@7MQ!>>m^L$m34auedo|8y8!Pfy{Lzbd>X8)lq^uZ<4C{ z-%bvEUpKqnBHk|#5;VIn80_rxmwf-HAh5BjPr=Oey_`r}R1{LCRZO|J#$TCTT%TuU zd4H@HxE*`{o1@Wl_Z%OqYci-v%FXDx%+=mLl7s5*gD>zCx02+)Kv?cE=v^}}m+Tz^h}zPEl{x_@1t zK=azl*DNtP4zlcZligwo2xFYUYL`bI$Ga|b$1`S@h-!Xh&25e#fC*-jnv&#s1ECOj# zGfbxenTwWU%erYl@VO1$PSga7n1>99rNj4Up$*?AD`^vvUq7`B`+{HNcb0u z=7=R(AGxHkM#Wc#&+~P(nx@6YMKk3TDRw_@_^F1N^LW1iGZI>b@YPKMUUi zihg40c>I}JA#8AVZgfI%(#CFt6VX!Amg*aq7uOI6Nskm*#Lv;m{BW^!@i4_oFwtaT zcf@Z@Q=W_emg$)fuX{&7Yt!*iRo9u-tC)8zPTv*|+h%Jgwb=958#Ykq4E+4ml<(>& zham}9*pGvK=u#pYr`0U{FMx%js<0-ggBsq^UAg{&0^gw3bf*@pr#KIa#|x(=u(wkBZ@=z{2Q z4WmNPk*!qmWRF1R$Fj5H1&C`^a=5V-CSwr)+-)pvEHF+09&egRv6_8LH+D+S!ziiH z`*HVG%;hLQp5#RfT8rSE%JI2*$l(R2RC7#D;c{TOb-9>$O zves>M#<$kShG9lwMhvld5ldPH$#EDWwRfBaqw56>?hFyzCAiigSwCSAIyyP3xGbaw z@LvTgO&5cziEVV2=k86Crh$+9&aGzl!1C9= zrtfLW+q&Xz-?iDIhdB;QUlr!*Apy@cZ_EB+kpRLc)RVRD=B0Lbny3|Cv&y3?U>4BqbUSm`6?_L4NipplMc5wCbYa7FJJX&laj%8faP}qJP`i+HKdS~l zhr~4B*sSWJA_dlmvKS#^az^0Ovs8$L!_+EW#LLRpWDC<9f}j6xv6&r?*gk6c@xYCY z2*LE#I46}-48sf%un8(L|j|oz)UV`N*A*6a-rLnBHNl!T={dWcxQL^M9B|2jnaKJ@SXJmsBrQozxt@>h{XqQ`yn??!J| zryXK6v=gD1i@WvbXz^1$Z|riuVpNtaj3SKO5CAJ)vS_FZq|kejKS26O!2I|)#l~AV zXg$@V?n}in8VbFk70hM;mI%lSHIdU?IlyJY{BxkOwuDv|ak;+0`^!>V0<(eJzh&m3 zgN+v?1^rB*!oq?F!1fi+Q*MQVVD+m(i`B#1n_fE!@tWs@;pzhM>Ta&{qBQ6Jzw}(B zC@n1G1!>M+OE?;-PD57b#@G%R?tWqD+xaztp{Gr&wWi2LEz@x)=_Vbkl{c8xP>V(@ zNS+3s01i7h7**SHSCdMi`=xLH;Qh!7^F#b1#dE!ad~BLTLO^FU78tYf-kS5aN#*If z?l}}xa`=IJYUw=#@P?<4zNM;W=%*m%H~`C=&w^iz4SOuFEOIcD=lH@s;U`0@9}QU8 zD-)ET8mLT|-!`h>e~tNG7`&lQcJsF_h;5&U`(^!6+8(*Q|0&?GBSzegb{-W0n_((` zJ5VaZ`4wG9L=+?k!|)vWvUm2p^;xkK=2MJ~p`>tCx-jAG{%b|x?Pn+r@|GtL7lYLe25 zx8e0GshjWS2H(bfx93!LgVs^EmW{_vecArhK1N@2i6;R;B-8P8?qsn`Njob&mv0cU*tUxgO=1vB zDI5(9R=ZFCz?PrUES(mu69~lV47VCMIyYGT(#x#>Q2G9HeQZp&WM5J5y?TZa6211C z-OG2@%D2?ucpyERF{LoCw97R|t`@V>Zh)%niU#F(muK@-x){ zc{JEvrnc>`(XLmcVck|kczgjKO~@e{n4j})X#YbK<@_>_+wJn$YU_=kV&Jh|xsm&i z5$MB*#8Y!VU4%gT_I65r*1hQRi6{$eo;Qs$RhGe+xHm*X3ke_wKu}1QSrZSagJNja z)rk81WrVZi7POAKWU?YOo{Unt0xT7nBcl{(Nap%?-C@A5go8&D?oL!UU#@32AD(>6 z1s_j^2Ip-Jv{zY4f2yDRZr+nTXOlj)#!dDLBq-BU6_xuj@$S?&ZVj|mW?nJ;DB}q> z2M7bC|G4`v1mHs^YUG6pD2^ENxIEx8A|hW`hN!zQgx=2CeXkyKhD(R37A^{~n}~lh zleFG@XPmX1tG@nHAd-vFYnL)Lkyl4SK^MnGKZek@o-|B+e_v*75Kn~Fh4=Y>Y-rwT z#;y6%hPanJC*^eYI^ZlpgzlhE^?aW`cj_=8T*#Sd9eL;XM=x}GFu>5^B-pI`%FLkS zX@dC`S3agQ6j`yV8}9~b=@iwc((_5ybcMX<<#_*?zu`TC}Y*8#N<_sgI2 z*LF=gJwB#10F|{rD~>goP8|ZHfebBKSu8n0b>XGNaazp8g9~G8mnA!%{AdCAw zk@iq&_<^8M5Qe63(zlb7BT+@e$WE`nt#Zsh2RgQ1$7Ao<)CTVGCe+0X<}aFqKc|pl zHePN1T(oc2AM6TUus?Snv*)OK#;p0${n439sVpnQIlS{3*lRVX1NC7*umRXSWb&KK zO1;fCK`rl;Rv!6rFl(aAqgIHR^u{&CJuKiok)L@8Th2#bsY7hNhu{zjX)Qni#(?Gc z0FEkW#AO++6N=5M`(B>ZnbmsN^FuIFS*Sh9etC2Dnhr_S?6pbt=inM1o(5-TY@Jop zSu70{;YeW)>5-(qID3(228JVBabwhdT!L~HEd^mNk@iZX7ImYOOXl}wtM1D&UtHYo zF9>_Kf7&`9g%#iVgq{Ljy><|MT`qd>eO6FTg|2qQXB_g1Sku^0aG`DBd{{~ma1dFA zmQaq@F7>8tJ#EOE9sHv~-GzRjYs*%X%TISG=QyhbfDo4(`@o%^jD;189)cCbK@;hh zd-&JGGL9fXnT&_d*vSPxL1y&uknm*F`>^U&>byOI&x1Qka{^n}-4VqWy=~kE-lw0; z-E-qsFUJbM>$#Jfe(w2dM4pAP3BX8P5-vKy0zv|l3;zNvxf2dK(*}lG zeWTf-3cbA;t`eO8%+8PQDpH{HG_?C=YU(wpkcLzgr346x7`u*l5$+|>kdRIsGUx17 zqpK8Hbs^&Mcn>@e_3->v6*-kTF!JwG$4ACelYU=SGe=p^TP$$Z9eYHsmIo&yM=vh5;dtFr<<(01;soo3Jn`3R6YLYc z2sZR*v`q*DM2wS;4w`kkQtL;ss&3%`1C20u@sJmQ7xV1eusAFhNLHw21JZ#QOo~ZH z`3wHU&*yG-Q*UC!qej#dnWREdZZ}PvNSlIN&Bupby=Yz+)MpQS2XFerdNDo^9I`qF zgo-5uPPxpc7TYJX$eyaKQN&lg5w)sZaj=ZNzPX}J;)%CsDQ}A}Vn_B1B=HqB2 zQ+6~(Vn0)=F`tuKKx|JD=TZYa4zj7#H!=-qYTHaBf&k!C3qh~kQLW&;9K4~qhmZ1@ zV09j23Hv@$z69TA5+fR=4b0#z4=j3^5j>@M;X)k$;R$0YgdObw0*(@;KDRHu(uMHr z0xizxm!4-~P1bz;#7$8}vyJG9QnsrOeYa>n-u;!8_Z{c?9~s}W@0#<@pBOz9-n%X~ zlfbkR*twZ7afHw)IExUFzs?uc<5KM9wTn(D2)b)b|8w;}eX#16CcuP@69d8bF8GIi zZ?l6$C-n4pK?HGPt+u3encNBqOBJmI;ZguE*eDdRz%n46BsmDkug7ku!VZP)n}b9J{9 zrC7UjK+Dr$RJj@%Bnlw5xw!HcNFAp55&&2b9+2_i{F87n`B{kck1(j3Th9P^Ge9w? z8=ScYt{D{&R(PN84(XIM#_^?8EHwfqlGBhw=o76tK{lr~$%Yj@Z2cfOT}EoW2%F<* zzGxKNmDU52h9)Nkg(OAelNYtS`R0U5+lyoVh2`few6~i$MXxhkh1=7v`(VUXHf-m9 zEWTJvxO{-}LhvD;;ilFwhGC*_O%I#lf80}(e13=(Ewq+lx(txa3B&R0*>uX(%@R2HK=6T`9XZO=0yUQYe( zqxt_*Y%Lhr89bUJ?c}u{qpzs=Y^WE|M)RyL0Nm%U*>o%d5h^@vEd}sPG@iUg>Dn@nQ1Ulk4fa zuTPx^&cP#w9Ww4JcFiraPlkdu-$fefi%^Kr!#`5*Mw}D3`rlvh)}pl}AcbBdBS33i zcYJ{K!tT7HB-DhE;+QF!n!AHibpsv6++$+K<$r=^Oh?UQc&5CFsKTlt&U4Le53QD) zFh7|`C~vMBcpHF7t0s|87n!?M>;7=4hqjzME#TvG^5-LLwX}uHCNcF{ zWl2q=wI2&zu1Z*5BCvMsMB7SOI}l5RQUq&iE4x?2^Gea@Kq&itjQJf|-s@V!pUdRE z9gXy@tNYfM=zZz_eI#P`Uvh*>4+E~DItY=2MhR+cE};a|o1R&E(8*PnP>-8%VcQVQ zfpQ?w?Pu0J9l(2D54J`mh^%OBuX#v@^{ei_p>(?9BRyD0e0&zkT%@V;%}HQ^)$LQV zC=fx{z-D0+=ofg+xFZ&DIT=t>s+`ov7joyb30$Opu=%JPXP-Z=buT$gwF^2@EimFH zK#Cd(5fOybf&RdRV9E4i>Wa|wlO(-u6pn&7PqAOMM2C2&`{GsHgDP8+sP6_(oX!cY zlRNRO{k+0v)nh@JM6KyP6-3^@@We+W!aV%_)mZdQA`@+l?zeZTSDMf9y4aLkBjqa1 zY?XIOdJiA|E0|uczn!jq9xh|$p&>)}mj)7J1W1#8HI}x_?F0BT?k))B!*-9dmL_~kvxu@`F5RNyl&o}xT)afX$;T=sH5|u^!kUy20II4hB8!` zfxn+-T@5BxT)*VvjE^u;ZXEp$^xT`;!+5*K9VyzxeaJ9DY2BrCuw3mMy4HWmG=9*h z&zv>9+LKQw(w4mF) zzx)K-P3($B@^G^G?#hU8{&)ix_0G%e!Qtx}Bv(Nfra%Apjiv62HRtmLdoZw0Y&?)sAqpU{b;$(c`7fT`m9Z*og-U5$D6pm{=YJV{!uNPyS2H;CfMh*BcioB_qKFV z3Ah3a6eE#%DkUSp3;{2#iK8+^-|Kd;*yqFNdGsHTGMY_)XMtA)9bOPNUKf3zNS7-S z&OHBG2Ga#Isj0$!LN+YSGDB*Sg&yNbW_UU;-*F+weeX7N!4!XZN%@lNqG!qDeBmgN zMFi2opvH-Fw}rL@fgfcd=QLo>Qgc;(mlSUiu}4Ih3Ru^taQh!lD762o)}^91*V$T} zHUPgo-QZh*2&PaXgi&*1;B3694h(iO0A3l*89;&z=!5r^e z@ZEsvuZ&}6V`|NU{n>y}sAHt#-{a4pVF`w25?n3bnGa6UYoMAQRer^Dd1(OiKZZt& zg^_yuh|+7~A)CoyJ|tKW5Ecse532%fR7(tx#7@^!n1-bN{QP{kVL$AhFk4!x5Y-v* zFK)EV;J@X1lYDkI`{DI_FBT)>JXE}>o!tST@cU-UQM&@;l z^9I`%=jURfH`QN4k77cODo7t$B4Se`!hEW-^{Kcq<}XwYudIrcdE?VX#i{)3b~qfE z2ouPjS}EM@^YX31-uWYCi^x`{SPps}L`w@;QHCtXQ`{rO5Ljs?JGh`8SaW+SlVqhM zYn=O{FmfyQ37`TFfS7s^1cyRu;OK+d@KQ{Otv6hUd%x<0+~o-IT~bOaRwXJqGy zb19|30I~4)_yT1Fk#XyjzFOH+TrS69Z2ehxl}@k@gzJG7K-Y?MRjRyy8CZ`gP|C;z zBEfxZnk{Z`QEiQ`*Jw&U+FPT!%^0^RW`vkZx~$?&yD84Y4lkPa<{HGLizJ1Uvv(oA@nd17nmm|alBe3Kx2gQW=)hfzptN*=2tYkD7Ho*8 zBmzL+%~&4aA8y~M?E@qV(>M!!21h3(eHKY2=wLm#qc>LsImdVbnJvda-9x9BnNxKP zKL`I-poJWm{V3;tj3lQ%dolDHW)EOn`Jtwc6qcFZ6;)2t=u8#|&@_`Dd&|FHQ;jP1 zHq{Uc{G+MK&86C@mPL}&y4L2od0FCWZax%}TNBh`sifAkz!ki6nDJ5Pl6&v=4NXw& z=qeiq0)|Qctv!vnzDD$uzHUr)@sPo$l%uQFqO~o3Vj=rFM$<$S`!#RG10W*6#PCf* zi6WGSM;!%DtSp;ItH&++<6noXtG_=Ser$J7J)`_|D|tG{Ka}ZbjSnuwN=6ORV>Wed z+lctQzXTZ(o&kun$b^fU;iABmXc*&RiwJKC8_|V*IWpgL-WrM1Gn-WG3Xo&Y(Ydwq z#B37msou3)-0r?>^(}KD_F5dnO&L&VAL` zMW#Y*9XrS{WI8n&SZlxo?Q;CskV1vf>#15x0kT`^e zTsn*uliK|BWU1EiyejK}N#7ejMZdeFZ1JATn%HkM3~=6ZpRl@dA4e+i1=Fc$@M|co z@Q(8}I9hv)it+%fOx}D>T{8AyAk3C7x$w9i+xASbz-TkmhLP;c;qs77{njqk*Oq^d z-~FWo-TxzQ&+Wo){yzY*Ku*6|4la+gyR8gm1lxx55doCdU{TO;6osl%s0562lVcb@ zapFi2wD*p`G4gfHoVmGDrRy9Db7|xiboA^Q*s>om7?Ld@0t&H=2Z1Y5)Jr~_<$0iT;doAbIwV~%ca)ZdbAXMs9kaxUs& ze0{Rd1A~ZNC5sj3;VPBSIWS z(K-OXtp|7R`R-5tqv_6}W3{LsRf%8~)s-*um9Oczo1e?gD*(%l1J;rWiWReKeRu|r z&9k=oxO{)y20*W8mE9Jf*a0Fe`R$8>1;HqGvF<{I@#!h-*}m&&v7@zn^z7xo!u0vo z%g$BCYLi!{pKsr>>%M)vw{6}#(2es?{^i$S{k32CIh_5*>c)hIhDwJ&^AkTm^uYUm z^r^90%XHM?WVciR*J9JaBK6RfEtolpWzI6y9HdxqzEJ+Z8?zftaxDE}OFlb)uFt(s zn!`SgLaX(6xP>9B63HoAe%@J9FgipfPSD-nzN4qNf9KU#PyGdEF0GrI;I#%n?s@pT z|K{#@fB5?^)(|GT1vNq4qg#v?vMBg9kWSZy(KY_P*z1=j$u86kyj~hb`S@kqH4;w? z^4mF+8nm<)QH`tEyMNcdnoac7Yv=w7@hWD4$4`&VhAm@{-hK3*_GEhUvH$+d|0wy+ z=fA#c-vshkx$S2S4)zuS`jA9Ce@^3rK4K280v@<0~K(@=_skfH&8!RchOC zMQ-dcR}}be;`^K4nw5Sw9^U-Vb0u?g$~YUSWjYD~W56~+Ku}wU26xy{_fDQZ{~eS^ z)?SPjYYl$e9vt5D;ZOWjrEBxP2`Qjr8BjqU2&P2?zVk0gv-Hn;0l&GnI^Z|&Bbbqq zxFz>mELaF)1*`*<;{+0Dj80F4hYlUQ<7#=j8c)PeV*28$@c`4;uADNeTzmPOU;e_S z@BGmpVS0KsIfttIQ$P53?)>Br{rIz^>9z^oj_FuXEVh9p1+$|Ef#5Y9xgIJZn}grY z&8yoO{6I?{PSf052q4LCheg0*P|zAPOFh+?Y=lRGM2u2=2_qt@J+8_YX^P< z11)Xu_~=g#-S?gkoT;c%Eo_76z;TS+XfAM*`<134qqd;l*}4S3>)C!^ANVc$?#$a0 z7rOyFg=!+>zTFC}&3Z^E)EKZ)h-xj4jvu@8-czriJ0+*aUk1ijDfuv!UV7$RPmI3w z_FcSu z_BDI{rPa^wU&Gc8{DzJm8vf)D{_J$erk!JH5oKsZr6OWegAmVGKAzL-Vrg5C;CDU8 zb(7%NxB!r9s8VtL8i=LY2NVs2A;64;+m-VGe@ zH4aaf*yGplzvi7-R)Q&U4IG9L>$(`Gx&SJ)V7d~cx2LnOBcjf$=gxc+6PG4eaS*O) zYXp8f3VrW+_up>2=aG+|F@dV7Hdqb-4H#=MuYZQrW#MV5?Qmw-m{@BEjVlCxiwRmb zW&=UcuugJ`;FWD0ZwsP*w%Ccg){KQQgknp_rosOH?U&A670@i>uIfW8t2!{|)Drb>Qel)M4p6AKU1B8a>R~5`|)RucJ z3rKOzk!Q>Q)=+bvHTDu~5rz?xqzVor*c33VK!qYECdPQnrrx2rQk^NEzw|UJV{6ta zj+g%tH(`6X9Nl{NJKlBGs&ED&QX>coB_!1vf~bH62OpRRd~*E}=q9_lcdb8LFc6(F zcmRibhI~Z`yI7!R$AdJf^AfE9q7g@JI8_4*dyYM@|AU|TSqvO$S?lw=VH-Ho()s9z z{=v3~KJbZ4D5AqlS_gxQt-x(7k4cn1t*c8K<)N_g&Ln^fNc6Y7_!Me2-^86Mexz5>&un4sHawJj? z=&}r5YMfS^P2IREjT8jS8s%~Ydw1>LH8MFh9Z!~@S52f9-Go?;Uj*Fg5MmEaNW(hWgd@DIL^PP7LLI;E)ooa zO;RUcrnRp{_hT=ButK>!i=pnm{tjc~D^EWE7r<0{UB|V;cI!RAfn>|3JB~k8E_9(v z1*ayV83gMZ-x#yXT)Ve}HEIK}i)m#A?c%x?Do(*eAs4eEM%< zaM#0YeJ0m!2Oipe=%e5Jw=SUAK8_GEX^7HbUkzg<^|EKk$H7%=*qpMFkI{|TEd#!F zdQ*50vAM4`Hn zx=foQK*V_=Q2Itb4(oPOthsshUC(AE>!sr@z`hE^QVc^W+~jl#-jpL;PD9ARj=hIJ z@~MA-?f2|l>od8w?TNNL_`V-)+js087g9!SU8y27LzrMlV8mr7Nf^o)C}RQmR+_%6 zoP~lQ$ZJ{W+x5@4m7kUCC|Hwonw!H@(x8lP$S}(M#=*NkT-tN^p0QK|Mj;3b&{-{O zCO(>8&-Q=AaZ_z1Q? z@aS5f&7wAR_trxn_~hSBdbS-HtqGtDU?ymCJ!_2w00LLNAqHMqLP!PN(@K<9R!ZNyh0hao3yvcg3a}bP8K4G7d*AT zF}ea&2v7(>JIO3-U?fI@7zQc0Si}|4m}=?Ud+0;o`xDst!1lF1tNAt@_CEN&A8Ffn z^nuf5LD16WN~)B*35}2!BXyEfPV6>;RTiRLY?&=X*{pGuK`zz4kgdDKhCi(9ec=kt z?j`$9TV%_q6WCz!Rnc`REh82+F|c8TNuz>&55Ma}Xzkv8tHtCD#9(> z@4&vp58)QGPj1;{-ge~Pcf>8dTaus^B0$miJOzN1fHX;wrm??K*97?1fi>*EmSIkk z-~S_Pp6K84bubj;|x;TK_xcH}BhZ?%jlCT)=F|vIPdN zVNzWyT0@ADwS9tOKrw1@ii#*qB52us?5;=9wdBnM5_S%mXFcH6ixS8gI{sgfe z2&~(fFr@%nq--L^>2Y~b0cAR&ihwwo3YY!Y|(k%z?pQ}CI9_Z05wnwC=&ppAW*PuU@3(Xr=M){Jzy1l7;M6fFEPmfxDx@Ejy=b0wb<&SNW}l%`V1Ltn#;$tq=e< zStBS5d|<{v#K6Ef#bS0e5Lg08=L?JlBMFPR8cWZf2Os?;w(>2H{mq{dkLd1qJovv$3CplY&GH_l;-ZAF-ZJkW@+sfvK z4w}F(b>&{ESg&)Zk#GSQW7|3Wjz>O*9rx{7!$T+DH~cZ`8#+8$u7SV^!vfMYMJx#n z*FEQXp%I$1gEp2(vY(LMk-HabPYpoAU5TFAjD!`NgrL8;mV$0@zx9Tug z8T?KJ{RfV}&$RUqU8z}Etw74fpA2R60|gd98EK9i+{jqps?mB2+JXS#Z*U{7xs4G< z!T~NNgu?c{d)p7(`7W%Li@y2D=3Vzb`u;06h-Oj&2O$UyE7o=GL)x&#oqtZ(46UpL zxV@djH`zVha38x!f=@R2MVE*GV*p}E7-1|mh&#J>b{)Ux!?&v9V|6F+7QJKhp}X!J zH;&N_?Y1WFA*gp#vtS}m!rfR|Z?!dffkdtd9j^MrkQ02bT86au;9|_Z+mGG-KJ*Ru zuf?&dEeB8Z9Xv8T!68x|0m4X<#AT@iypDN#Iz7*f)cjfdH2I;*>+3f%Bu^0+jl=)+OqHHq*1Usf+5#&>1hn0c~1QDcC)v@{Lg?7S!X*mh(`d57?OJ3PTvYtR1xD=A?n|@y<`8; zcixJ_Tt)EPQRv-s$NgzbXHSYEkT|*ukwCorThF)&3};!74ZG#m;Q0}`svB72`+*R| z8Yms2Ix&klXo2bO+uXHx-#c)tCDC>!ySw+?ao0#SMa%(eDM3^KrIe%hRBNCc62`bS zV|#;dSnDHR*r@5p5{F+Gf^{K$Pp@gvnIr*_S}67j&V&F8F_kc8C>-eBec)kq?`dD{ zQ(8Un>u(#{wExh)X{%8)1PTkT=!H!tEdr2JeMrewANbK*yldO-Vyndy1h7(`frvo- z7Ll0iVw9-?i-i&ZCIM^;1R9A+fkG=Rg&3ot*tKu?er!In=@yPEwH$un1Hr(KJyVqg z#t3v!LXr{;{FX`w5R)|)_q|>gN^)E)k#RG$t0j^l)c85q`phMGfwyd+7aWU*^Sn+I zNb~MaJfU8IKq(+KF1f}G=m?fVOw=qow(Q=8fo-dvR9iLhQ`@%R8TM@2F`WuP0Tk&v z`PK(Nm3R-30mH7L4Cgv)8@8~m^4O4tYnz-&<@-Rjr;i9}ObR0!+_DXQgWGP|X?O1E z=pR1zk!sY^lZpZ?LEsuOxh=dCB4uCNHjfB@vmkOK@0$y*{k{hHvh4#GZ7<2L7* zuUTA>HPLFQEpK8p3i77tB)&_Yg(saW)DR-*+dLF(-Ftks&vTW)Z%;IM@XmLs&i+l) ziMUShAlR(D$aU2AYQi1>2_Tk*Nxrv}*SFp+8_Pw;v{8&Q=U?WF6+wtG84IG`q28{2 z$KC;KDc+Lf9@@39Z{P6dDJZ1ihMSN~a=vKFBEpCl7D zXueJg;=Dm45X*?6kd(Um+IQ}~3)oRu%_ChM@Y~k8xo__scg_I1EY`Q0v`t-~G7qob zV|;_$ysEYY2+RcxOYr1#pPCm}8|z}VjA{bJRP5M1eE1{Sv8m%$9DcNI_g%K7W7Bvw zt!wTfE9G+lm~E*R-7?-R5Wd|O-zECyEH{QJV59&HAzxHETaT?%6CX2F=-9gNPV{zl zujXm44&n^%K2aFjvv*{+3ep-@5sdG^mZ86Eu#+|1$$F>Gl^1*)^QBhOZeR>u<&EE} zWqNI17{J27LO>j0d?rT6w!J$suyyr?DEo$6`*!aio`}=V7=TsIlM*B#F}`G#AYQq7 z)yD8f=i*|qySK}{&Y}R~wzvSho?4tetP?yX$pJ7Gg((WrvUSG}Y}s*WH4ks)!LP5c zwe!H?hij#_?wM3TK?qUgvbm%H5ZF3(VaYrUUl=V??*R4Io*e6FE3?2jryv-R*ZE^K zuHJ?Op)O*!Y=M^UVlcRQ->RQbXYbbT?R$^SCMh@yAj&PM`Mz9S({T}jy`IXAg^~)_ zn_yV%HH(4UV!t>3doiN$KNA*vj7l?FC#!)nJY1_rjb^bc-gt({0IvGYtR z1(W#Qg!YT3SyyYz+tcc?K8E$4U-q0=Y4TcXLe}?_aW&aiF>275N}zTfIQSq2I|oi=i|ojBYx^>5f~80z*N8+`;(Ik5dB`MwkUt)YZEm+YYZTC#$1-GfHinDT+`@ zVy{amuwLqniG29vI!f18z?;gy>$+a-77r5fARsvyG=su*+AY`-El71)=(+1YW-nl} z#Lbn*7Ks3@F>M6hyAJG7{o9VL)_JZp_;KgXy@zyH|1K%EU^Y$=gdvb7kT`LYL&Pvb zNJU`CasCJvkkmCh$uo^K|FK-V29J(#Jv_VXm;ehMdmcZ-{OwMI)*5>ea2A^jjn=bg zb3A6y9N^3DCSOjpShjJ2zh_$;wtzN3m=Lt_SvX_@vlh?-s~OX)@zEu9DsT~LvSGtBRc2kiB z<(^XWB$n*ogyxGbejDpQ(s09mNEQ<}t(Oe3WVadR-(#CQg0kTMZP~RPg!O$_ zrGcrRd#?mQBTWQR*cx^9ZQ6Sk_##$A1HQd~SKrp%+b$#mkq8P3*4Vo88M(NT`!6p; zxV~$(m@O(O+n5M&&}vrO624?z8es-SjhSHIRwSQvSRFd|Z4HF;0JC1!BPzOZX(Q!TYl5ICC{oE6c(H7?q=Y^ZeJQK_Y^ZpAOK>)en+D6dP%N7mRM*g%XTh)a&A^A>l+@~`f*t} z;4=V|Mw|jE>!_`}cMmYU8b!4_+Ohk-u)C*wHf1Ok0>Cv3ubFI6^x$@SwrK&{oIJW^}2#P&DZH4}=t3r8M!3n#+t#8+%N8@70 zwu%Kv)m1}?8fz@qZd-e+OrZT1EX#0b&-lPRxFME@rfrVNJFGQ-VrJ}b4nqC?S@Os| zaf3HcV@X#289rlb+;O^Xo{yo4f(S!t4Pyx96dhq_?*K5N0mLgil6zGD-u-vaK(|y< zfC>@V$Hc7lL|Is`2ret0L!Y0F;HIH+yBq5kF0#H>-R3=#^J;bPG-r*}=bLHxLT+gM za4z#g&APRF^Y-nNv9u>y<8kYN zIFIoGt~rq#A>@gnRSdIGLpZ128>Rk}@)(}GI3xtN^`C52LxQCNWOF{?&EA$Pb$6}4 zbF!HCM{v6tfKg+GS#V$b=A8%7-m>BXrz_|A_4aNq4(%AcWEEn8yV3ngHm#IRXWAU2 zc}qC_r1Isx9_t~$wr7b8ccvzvV{v^LJb1cB+ga()+$@@Sx`+Q;mf>u?X|lw}jartk z5kOf$8-^=RWH2eDOdtYkWB{d56+(AMM>lk-0APAW$1-W{D7LkCj-*1O0&{qeZ+(L%xC#EvGOZy$0RQ#GjEVrWthsm7)=e1d>ctesR`P3C%=1${J;SD@ zeQ+!xB-wfJ98kNiILtcCbX}K{#as_+p=>Ms_6RO$O%`AS&wcP$-6u+f08y~yyhQ7H z@=gVN4!pms4b2>6RKXR8(?(cZIwM=zeFf z?&7%#x!#&s>$TVa>jqN*DejaWH>%j0wAdYAQTEEBmzd7Mk_qbtXeucW-6(wlxr}?Kxu*s6te#6_43Gi zOmsDOZ1X%k03xR8-(fQ*IWl15j58_ureAa99Uy9Rr7 zu`P&-C1_Ge(-_7YKm`p|8@LIuUwRC3iX}wiN-yf)qg^fU+*UAR;i5G{gbnDS-{=B#WGP{`%V^dfwDDt8u|~Tq+<=fl>j} zX^ieIyLL@Q9lZdqtkn0f4EVJV?$e!}U9}_zIC3?YSbUYrCDU)n^IL~$d|t$H_S`S> zJTQU+DYu$2GmNo-Vi2*aS1B;*- zB1tpCH9Qdb#Lyho;G4B3_sRs^IecN%1&h4wbt+TP%x=jSU~Jl7HoG%CXA?&)tsN9%R~FRs*At_=8f z^>63)jvi|*ShMFf)dSKDTHMWT1W5K-yaivn&VX;;X*8UNK@N0o)lOlpfk{)OKJQDc z!OE$oWHLVx0jxnNDU@~WFDnGg3d{kZ6|893)aJ6$kG`Hkgl;%EJbwAYE_%6 zK^i;_>p(q#LXrq97hnoqB~$3?ThRgCysfRYZTJ49*xsE|=n6}03X{N5UXXH`;DqdVwAXXr$uiIuoDS|O6Qn3(G?p&Ci zH>=$>6jQ5TKOX{Yz8-TAws8@@J-j}59365yw} zw|9#PgUvR!&`x49f0BrrF4o*kS>x;Kf4faqwg$Jou^l8dz@D*B*1BY_GOlL{1R)cM zt58H>Mee3H35;U|f~*C3x%OBHsZlUuVL9-bULWZ9BtIh-P!=Sr`6gYeylpW6%pIPL zmc#W7a#lo_TBtBWEfpk6!4%p{tvhxfo!S2WO*s99i*r7a8a`P8!wQHAsQT;#3(#(W z7d>(fEA%16&K>uw{w@1vIYdPO9Tp&*Aj!JQG7{BIm%iDeC zXXeI*$md-Ez#0@JMMM^n)(D8e#(=B=8S6YfYY+oSStDVCSQKJ05K`{@**3AEd5%D9 z9u{1%MA0MlQd{4(jvLM`vuqUCU?3F(B8E@^NevQFNJ(|Ic6V*9VNV2{ToDXh0q`3r z6gqnbW7d&~fUQ4pASdYNy?X89z}GYw>mB?S$WYa34g$*tLINcgnt>t-iX_k`hE5Yi z)=t~<%p|JiNtjyI#&KfOstFYZTx>6tbxSCv)^1&B>9=8sDimtas0dIK*F+pl5XpL3 z5YlW{MY600P$a%A%aTtjSa3>Bd}}h+c{`#|6~?$FHE_qVcXbDo#gW&xJ)TtTG<3*H zQ3%okCKA>qQo6`W1)2i|CIu_7R*cxRX62)n6&Ye`-?VLLTVczd199KJJy)cJBo-*J zSCM9)apipLn-QuEdo`OCE?KW%Ur)19DlgZ}DwvdFz#6crVXzIL z9M=-0X$osd6{4aIbRj7gOG%;FTZzK9SVgD?0a8*(jfN4I9u+U}@7u`p zTfp3T8jxbw*$o84goJgWrK`Jtv>Oq|uuj0Qy*G+ly9NwvfK|h)=t1ln>9^+0*;;RH z{Z@yvNt8__N24Gg$72Qz#Ay+VKntM7AOOIULZ=mk$|4{@nOIb*(sSVe3Fy z2f+*lm_mS>M6d`w5UYI51>&mEa$(QLz`1{2&LxvM=bjn+I_zfRsfhDP!Z1W^ocIAk zjjJ=W*xcE(_wa{4xn0e^=cAQG65>EvFpCh3g|?uA5NlG5BsPr=)2+2>0ujqhLJC?? z3o1IK%d>(Rz^Q=~!6XJ0=)8v3AE`T&?3&R6r`BZI;=1@EYq;UMH_C@WKy1B!U=5VS z2yBWL0!jiSd^>)@ML<(}AKNMn7NJ!;M)&6MUN+rDjg_yig?pohT z*R+MoDjQ5Gg)|YcA{RnXA-J`-4UD-^ogdk6Yciclk<1`Ae=aigZHZ~tpU z1`TsTZxy}1>&=2u7EAJcEe-&yz*a$7>!slmq$U9=MnQmrsi9S;=s*oE)v*hc7v6mJ z!ke!@F@E9v>lhz7kIAts7@N3^%IFlLN`lLmD_C@yJ`tpSmFPke>I+?^4zvyowQk*d z=axI~`pCf(_uhL!wRWAa8JMUQ)rxTx8CZZ~!1@M1A^;3XJU5Tnosa08$eh*fikA^1 zkgGTV-0S^iv8vg_jeT-~Lj^HaP!!>ET*KJ;a;Pka1r@mW`E0Be^4Wh!@HJ`Uxhe+f zsGec9Fv3WcLP`M)yTp!I0&~%C`FtQuVqYmP&Ks1VDPdDL4;cQq#fZz!N@*K08yzGU0oe$>)G73eRw$NA3WT@_rQZ;|Ip3}Tf{gDm{uX6 zLU59RtRRJgR1;_}K!cE)8YmI6<+#R}L8fM2mQEVQ*$IDjYx)~ex-UPQ!Ttz!@%7l4$3Ac7c+TDgKi3zF$EbcPna3J9-Ue)-(ffAz(yPkr-uaOH((aOS!38<}M| zcdi1Uf=K|t6`TP8JT?Bc-~D3@9O=DsaNj+>`wl(az5A}awrtxsdj8dy?iZU>0BvL~R8Wd;0;rwh~{neDEu^^%RQ5(zJ2H zmzWcrU1%%Y6pX)-<|wzoSS;9DPfWGV9#Ga;oefl6#z07DDUV+nefG)Ezxkc7e+d^( zejBfhoCjW9VRaL4FFba#GI{dfKmW$-Upw?&pZSSx!*_l7l4|WpQ3RJ-ftrCL4bza! zpiE;9@EzI21>eE2xqgsO&SZ3sg)T#bbd%(t+mOI&d@U)@k*j|{vdkzikZ73$KI|l_ zmKXeOoK}nE(FEDGr5ps66~IQ2ihxN$v4UknV#{Dt!Pd?aHrcYh^sTS|;p>0#*e}&y zd-kgse{yn#ue8PGXRqM$vwt}9(pSGcQare|c9t7)P_LAAFJtrVqd+29v-weM4fVrOhp zG*Ju|7*J}+iUR8)#@|qyHPozJCD%KO?b_{0*=49x_6{7h&|twn*Sbv zkQxD#Kx?2J&!R0b*xXS>G=A>fOOO58e;WIXFa9##`194j=9e$e0+(N|oO1+_ls7vQgi7`Nq1gtrN>r1#^zqLcl@LT5Rtam|3Ck zS|<4ID71I?@8+=BVFdtduWmt$23&84@*KlXX~S~X$cGS!AY~^_A2K1R&S31tXTEH| z{wJTu8&BLC;A^yzSEjH2(*O3aLQ_pPz4N<%GHl(lb0p9xn_3pkbK7)r$Io8+-ICiB zSw_PRF=to46h)@#W%FMr@YM^Raw1viBP(!NC<>C3J87w}YuKhQaBK_hFy>W$G^n#= zjfJS8UYA`4Sp*2805xmDItACt=qqRp#M9$n{rqqKTl*Kk{Yw}-IkoE7c&@$tr>9V@ z{@VD?;k!DIKJYz>1T-tL+Y*W(uBJd3G|-r?ry3p}Y#sf=h+S}k&po-^*1AXO9CP)oUT=?`pnwvA7S~jW#q;!%S{2h% zlbD{l^*2G?%=usa7ytUX-}~?XZY#%cbf_9aRH0#fp({Co3E!BBB>yw&UE`ajvdAtT zp1hvgQX=wwKP1<3%=HF2yVhvBFWvLhL?)hVi%Om^^!qif{@OhDKj;04tw9O~iJ8FG z?hw6pa^m?v{H=d&zx4#0IkZy!RUS3)2d_Qo&9R0ShE8-vVA#57pwgJC#ahC` zPPFx|Y+2V$9xcB4`2TbAFaPj&cDFK0s6tcfV|mW|v!rshovc6~4b694{|{f)khzBo z^>1wiqD4W7n%`rst6R+;3#j2VDdQ}q_^}$JH|IkQ60{UFP#(pOPKEIop7_H{zxhl5 zCnlbrSjB62eKznw|JI#5?+#l_tu-X5p@!5}9UUnQU=5m7m6}fTy6>@d5Q~?3Vupwy ztdIseqC%+)6|*8-zvY3Sj=E56Db!K{=>TbBoP5(FY9~z@-iB;s<3(AsBuLW@2;X^< z07EcDs4}^p0-8}(j9RIL;?`~Z(9?HtZ4P*jjeLFj%y0ebKgX%(zcUyZsI-E>$Ns>| z#r}c_;uk6hN^o_^US5kxxq9S`Al;}-w?GqFVe5oV=Xeif#S6etPPbuJ`Gc}%PK`~S z8Xm&5!tHoFa7Rs z{(GE%_S{-uVgS&6L&N?14(`5MtD&M9HejHjU`+~F8pmj+Fu~n>)_UUlQZ?V}#QS6I zJborH`+goKMXEG{&aN)B6jnq#a(UpVqBhH6O9}-mYSHcNJo8>fle!DmNA{P*DM~3T zP}Zr1qzo9&Xdtc|T?GoJqo=p$_}veq{m_c)jNP=o{>_t5|Nd|N>z4BP*>;IRX&E|n zY9UmN9M*|#;KgOJz=fN2Uwg2wGZ6wTxP`r0qd9YDsn47jL-gXg`SCaIJSeN;5VUJX zPc6o|uYK)5qjvT$SMVw=%LWe~e0W-^t!1T=vI3y}bwd^tKvM#1z3r$Lu*K#J;LDW7 zv2J75uwhg}pw|KTwY0ZDg~b#M<7b(x`CR&K#5~X2TMxkJkEX#R$r_FOxR51eqNCLW zsObWB9y@U#`nq>hXhox;2| zBr56&3-+qVf~>)JrZ?F9s2|Er&~g{G+<~8yRLQsU)^Qrixr_i=5DOFu#HJ7vW3as) z<&!7Bb>T~2`DMJgO1#Y*wYGh|-NOeT7*{%&r2w!YU;~74fG}YMX$ox;sJXY&yyQkh zwbeSb{78N6G~lX*5>}9~P()a4Uy1Ko9{3eY?Fb7kpa6i=tINo;OP<_%*yb)_dASqK z0Rw?oQVw7ei-{_rdxkc*A2{{^R->B!W^Ck@=~w^akN$IUYUH)PfPnH8ND+`!A*xY= zP?~<^=S>!KlMgp+b3LjXdh4}cpA)Asf8lJNTaD*6Co?>Ei$~(BBuotv5_C{);)O5& z*)QPbKYn#(F3bwp&bmf4Z!Zsiz z#NbjfK+v)dJioA|WSJvJnq?^FrWnh0_exkta`uw-tYC&w;^c*_0f|LmECSgUt%%x}b6TkbFbKm^R@ANBEE0`)Y89-84n?jWLc3hc89ylU!=HNFo@b{x9CZ3e{kOtUV=sU^m? zHjPWqKJoSVnXmrtN?x?(+UAGf`@N=fa9=HJi;6lK-IA-pHogj}S=;e8w7C^vOh}l( zg<^!L*s>B|u^jLt1iEOU!wmc&jHUQki+X(Pj&tPi-mJ)-1Z*sn2$UEgO`w7RiE712 z5~6kIp*`4n_>o(9fY(AObLP*#`0L5l3uikc2B$SppkYh`YcsJ1aWsgm`A*iKgBSVe zYc)P6YV$ zYt@dOY*-Ld5NZ(A zkRU_|#W#>GfL5_UwuFKKSA9ua*XPjMo&{hF~EO49F`F84)z~HF2Ykc^+WXy>|n# zzcsjJsUds<0EPslgCHmrN>~B8?qz`AFtbwaAPVb~jczDgxE?{UF=#*6vQB0y(2l#7 zrmz%XmV(0O19yH5Lpx5a_3_v3k+~1CvxiUU7$np)5oBgo7pV8g`8u zhRd`prZ$Je*9dU)V{y2itOwP_2=3!iJ6xdhP?R!uLt=p834w&hVa0y6k^s$DTEOQQUjJ4C6%C7nFKmJI}d#F zvws6y?%%vNCos<}&b|4~^2O6{mQ(^OH5ld4MVSae5+Sew6cLOTK>6+P{7IWd&#yl` zkyRGRTrl+o_souKIPbHr4}3GeUt<|vQ=CtX&n9ai+akgODgjgs1QwtatU}b{6qXf= zkwz!Qm_GgDnfT=MUt8;om)p*J2ljpRyMN+BRMVQH?d2DV)2z8SNDUs4z)1ul>tN9qj&aLI{nJCzt=T8`C_jmQ1L7P zr&E}+MWRxq%JBdJK&2Ww4ZvCR<=esLN^Ldq4N>Q60v_BoU>TYMh-HvgfT^J<30h|- z-W>VnV}Af(WxH_RtnJzNo@4L(&}YVs>ZM{U;#!J=YN+hWPThOi1Zm3NzeP<2fez7< zE_;i(tPfC%f*av-cq7?2`ZwlHjAlyTc#c3!Ts3W3#4r01Km#RLm( zNmk!0*?7@Tf_SB9$+urxB9IMXz0RQ`K}Sd!edYP*aO%_xx8%B9%XUAq=g`N$|L@0L zn+{J=h?EP^Isz>DiibIwcWIoj>&=n44!ahcz#9=EP)b8e%brNfMNj3M(By+{uS3;k z$&+6ToAv6DAAw;C)Fta0&Z zhDA%H(H36F1@kuB%mPq54lEz|K8R5A2yTN#GhzC&EZs zp{62CaS^Abt#{iaAN}#-2R`*f=o?-UHu|#c^yL?8waOJ1$;*rF3y}e{W8{m%d572_(y(r=+1Y1{EcbD)j~UxM8HuI zagsW@FnEw_c-)Sc?3%W)?47$grMT=~TOfYL_Y+(T=*Ptp*pbCsMUkfIl($D9v4A2VO@K@> zSzJ6&n2}h5E_C7Cc#Kiix&NUb{^_67@A%+fUo+*G-S0b8de4V`vVF_;9nc|a)oShu zDXXEZc1Zvsq{<~iZdlx4y;_#_6$T6CF|kw<7&fpb0b2v65FE91w;Z_h{n+;IU04f8 zYR4mmj$QBm**ib^xu1M>*6yEFMHt-*AaE-bxw5=O5&(5k2uNL+!a`gJn)T#J04p<^ zb;&df1p!x=O-Nk^OS&a296a{U*6sTqs1!O;W9`IDxtU~HKlnvl!UZqX8eY7d6iWb0 z!)}T^ql{d3O)+wANak%=3lf1!1#B5rL$IpYwrTsm_t)Aw3$l(4&?%qSD9_epkTvZdmM0yiPb7Qvqom&S!{5}8c zj(2_X2i`~v+eQ(gstT~IoVK0v!k&^i1sW2th_CV>SaH`+e8W&LO1(g0ai#97n-#pc zR%xVr-u3Tj+}AS{IiI6ds#1IH*(WN`J@=Q0$Cg!oxC}lTi%o4hSBXKaU4hOZ>%a(r zuxnMBZ-d!)c4Js+TzEVX`o7@AfC(5iD&bZ&us4J~(vnu6ue99luWxZoB_{)$)}Kli=14L{SUM)dZo^Fzn7xz`jSf zb^AlIjU=+$)8@o6$qP8t|Cj)#jyu);vNrHK3PdSv%|zURj}hwr=ZBDW4*m`y>22*Ly{0%Z*HKJ6J# z(O(+|ASR1pHpDw|>-l|k=(4Dp zo(f{Qk*V+0YuwGXJ~-MpZLWq7StcMWqob^O5?g>7oVbNMC5=e~fFtO(eoUpMwzfU} zk96<7|D6L5O@F+6{?u!iUwYx2GjF{3HH=(7g^`&VOkbG=PE~K}_&RrV;_%%^_uTW& zPYoQp_kr1#zT+on5=r0S1d6O%teSKf>ph2a%gU-30*+N~V|hbJ)F4`L z{cp+N6L=oevTa$OUusO1ne)hG?q^M+!1~f~4SgKPa0CpKust05(1VrvxM?|-f zYQ_DdP{b??(j-M1hWqaP%-`5Id+zlgo;dl+*CyV4?Xj^p_CJL;-ux>}JUxkeq&J)9 zjYq@iINX66afmeP=^Wt z098#%L_t&`#=?q$^4oRG4Pgk$3Ovm1d+p{)q-AnX#w}ir$~6E$PSSx`0M;)G48Z~vZSAC4+hlRP_p_R8tkPEB7v`_z@wZ$51D9Z7+x6%>^aTLVi5TnK&X7P*@WIFz?xF;`3>Fa-=+db!2*U^vdki)iG3Or%@i8v8I*?D;>17v=rOAI}2TX zLv4NAw-tJawoIV7?YY?)w%886eH$h#71-HXa1eq`jFdRnTeMltyTI4D`WfnbB-aN|F3%t-PXSs3MGY81Y#1tPM@S`Z2*x$G5;GhU} znu586)N*XZN-8!=jYVt)DHN<$xH?tFOeIBJHlU~j(osMx1m|TkI3(EeEKn;0Q2~W0 zK$@mVjrd-~K53H^#KiyV@?WyvZ=R*LaK0xAVDnCN0^@gxew)t@GAiI@RJd$1`ZB=pNsG#!X~c0tg-rQGU=!t7NiP@iX|k80TIL4)PYd&|8MU-gDlCe^T6+% zdo!P@sxH&sO;4NY9zVbg4B-e+GyqAETxlt;RtoOQ@YjYSwA3;byA*<<6-jBeA%;Y8 zAvypgNP++efFSG)%nSx&Fg>QHd%944)W4@CGr8*H>!FV7*5Q4o9uyPL%6G@!or6DQ>7z6g4B?karCJiQmF$Qcc zwC^K?z=j5q4FMZL#KIT>Gy|R&SEyj_MOHd4ps8&P=c^T71)`POZC3im1h8QQSU_tH zCW4Juik>0_#=wRamI195h?JW?0&A^d zsjO~X_Ip*Rf&dlyN>u*5;+P_VtNxHUEy2@H{7Q--?SiVJ5;0C4uQ~;+1)_Yntcm@( zthprI33j3i0)TuEL40D-%^AR zq|Q}b9X`%t66zcCi2>JKPnjxv9Uz4M1SmqC6H@6s3X9Jp`Fd= zfujL1u%aQYg!}}>{IqAHJR}5>xJy^a6_amJs}E@8?6-@)o~no+i5hLW^hi`Kc~1OIZtf;VXif8OYVirxsJ&@xmg;K1#$UlTdIp)p(U0OTP$dcdai>~u%&V=vH8`E zYo_uTs`^)!Kd*O=t1Bbs6ns}~puBxsxxbQe8Q%k_z7|U7s%lABId#sJB8fE@2R+K; ztC-_DDEp^djQEy{%7o|mZ`MW-M1T#7rcj&$o?UUy<;oJe!orMLQ-C#bgj7mNrHdxM zjb@s!5VdntDaT&$QgW2F9X>;0}} zjQH%ft02De=Zl#CdL*~p zn`#litL(K)ltg2bvD>mrSjC0ErdWV<+*^y8*K#|_dJnpKBv^MWbt9eYV<@ck9i2ST zsdQ-*#2WY_hzj`t`NBjq_OmkbvtbbBW{MgHWQ}v}F+<`f|7LhfEd}nR*1w47vkVfk z2#Isn);nEjG3RuSUk5<_{v5VECn9xkw1smHS9p6iI-Z6WbwyPSuyh}2J?po&sd}CL zH73s0CB-$r+QgJ1yw+ORWJ&-J0y~on5lm0bzk$VF4=W?TyvgS#CIgS)nm$+fTIMCb z&9v05or4AhS2N}Z;Ui@RdV2&BRK4 zoQrr#y>qvg)huC2t%BuSLgRXbRdbbXJ1pGXfU zGFx_m{MDLcP?($ey~>lg&;&JYPc<2Yt9@W=f=1a6wf|;P5V39vYy>`JtS~Wt3r(8f zm8(|HObmy)nMu9Pf~NXb7y0mfyFxiI^|VrYX6Hi;1W%JjnX*trl4~bKCWzm?$SQODx);v;vdM)gmhp zu}Q5`)ylQx2ByRx#8vA(vh|6OCC+`EGd|QOz!u1nT;_z&|8Z@AXalJ7P=jkI{;=vZ zCW5CK;p}W46JtZo8pF!T&tSSRGd;OQF}SAuYCTbJ98JrRU+)L^awW-|dGpV6!LoA9 zWn+ma_pv}L1x0|h24E`Z_r>bO03f7bO*wc_uiB+N1ex=9syM0pfFC zs0t901yU&wGn11!6bs|c8pq1XFD1du^we~3%M@pB0IX*~W|gAprN5KQ^Ms9HtO3MA zDFsh6bX>lsQ>nO!-z|F`KROH17z1JtRZo}(l?|mNSTym$wq|qOjOD}K3g<2%QUD@| z2t3W08XKKJ-cGG_>j_p)ej~*&m>$2zQGOEA(Ji{7dt02#MHRmAHZ}lAO6QKPi`7b^ zgpxEchCo9*y*mZ~3ndn04ZJXbLIj@(9ll1^GtdYFlh5BaK|TtjC}biLt(ZLD7v-f% zc|A}%W3faHPP(@T^lL4m<;B z7r;Php*am=jnKM&vYr@Zq9S&r7+DiRhqwD_3YR-6?hm8402uvLIc18N--Ek2pbra zbCG33fcQWv1q4w{01G^E=PSKjXj!}9{5RR6TX1*v2yYRLx#(o_B_7WlF&5lJML$~{e}l-43HC50J8g8bAtQeA0CFpC{&g+4aX)cMy>T)TGR z>G7*)PvXvvi z=LtetyzuPZiw)*etg)t<874IFnb4sX!s)5W>5+kJ&D>>k$ZsOLHak3gqvMzX!GNrW zkW?sjIP;oeOG4$1p3Dmp&kD|Gqe%2(oaLK}5Uq+6kS(mDlI8AjoG1r0nAQZYUZArLw@63 zv-!cn8(pRd4dbGGai4=Ee+vMtFo3u*bJ7546)>M*WUHj&v@C>CIyX$NB12K!P{%dV zf%f3r2$^6S_jGBDy>a}h^H2TFf5L0ue0DLrSdt69zrQfn|DV4+b^G$jeZTZif1~eE z*H2xu@MbK*at1ovg`yM?h7q77ZdJLNm%P|;MqjN`HNn+?lkz~3K?e!0UAy}Bl2#In zKy%0s&qleSU6(08HQ{Bp^aSwT+d}2UR{|s{SQisJ0q0tPiQ4f>u_(pd2H)N6OJEj` zTV_U%{8%7hL<9g9>68Z<8^Qy<9T<7-*(c9`>9hY57oWbe*zGT4Ms4(?uRim3-b@|- zrC+rN9(>=22Sj&H1qAUkASg&N1T;J1+vME0p@^??*cGA?A_8UtiG?cUr^c>bexup@ zYhH5R@bD$%CI?dp;(i|GkAxGo50w3UrJ7Iz#eGRFtwmiXN{SM-Bv@GWw zjG9FlOo46WE-x?Xsl2hVLAk4GaKy(jHbj~kc4BR&!4{XwZHh?uF2~!?ny^TMYGUR05T>{KWz1f*TUHNN3g=<7ja^@ zQHYEp$mIBK43AxJ@?M%qel$68^TOGyy*}BDB9L(eXsv7P4F#8iz+7Dlh595|HOgfl z-r`=hx^VTZz$$C8kw9g`_nS7$O+=0OW_*mo1S1AoYnUj4jbe>uML?KGHwV~F`CF&| z?sLD7*S~mtU50-8$>T47{?nh_FSA#6QUJe@M}`?Ha$10MO5Ez5m-+JdlIvj=9*nE> zsBOtB%#EypX6HOUdF6XBg(6a#(dDb$@U{N)7#^Nz_RyL~e&gx985uf5Gt)Pfji3n7 z4CmXkp_>Gkyo01sO?47}!^@)eOtVHOui^XaJ>W$gPX&Ytp^3nx0TbtL7W2p;#62m( zwU?fL9s?J@y{=As| zvl(Paz`5z6fs1dw4D=V8y{~1EU!!?G8-xQl&O~EF11T}kWT8aBh{Y;lo%K*FHXyFB zohp~aZLAF%kTq*UWDS(3%X)i39=k};Ju^CZo}L|f?gyVK+`9Uz z?}Z>AfTv)@RujxBT5)YG1C=MI(RmRIp9P&{K(pgh;iZcwS7Rv4AisH@T|?L3E)L$g zk%mBtg_zI#wU?)mSAgCxn7X&*g z!tfifeh-(fytLlq1pq|F%U9p{(UTNT6(O_WlvkD#+`#AJu0})U`tk?LQwVd@*uoLQ zq1zJ}n`vrtbAskdv5csZsarQMc{VCC1Y(V|{4_aLLTl&L{cZG%bYnz_=f|A+kNvcv zi6ABd1x55~pd&wZ`}~W~d>P{>Cf9q+0D%5y3%Ls?zhfqEp7Wv{tSLBOOO-(O#=W*S z18VPoh6Ra*6lc-v35GA9eFf96OsvMfo2)~1Hki0|<8nANGoc}1u@G1&h?6Eybsh{jsy$nIdG^V&wAqH*jfsaJ7coe3tQVj?P>*Epig9Ic6+)~fExFo?qq_x3^Y{5)s|7XyS&$*H)c|<_doF-R&i=8X z%Un@P0mcPK!2*jSh$Hl5Xzb>T&;MXP8MGA-v*}wL9Gu0#_19=(f z0BmgS#VJ;I8?h~fr4d{6@1l9{WeYJ8n9Ds{m>F7YKmkmGzHADk*ZXf_boiCkoTi0m zX*t);_?^>*;hPsbJ%&OA4F*C1EW5%278vamgQ-f{#b8Ok%}sVquMHRicKs8K%hlYH z@==%?zlHSp^m>o30jN;Ok1|uCc8NQuymGS<;lgPCgh+g!6UvS0TIbBj|5_mVJ{TQ8CCV=8zP(M37@s zLjd;>tt{cUphU5S2^L9)1iO+-V<3@12LXCw^7_OZr@jk38!cz|i^Zxf{HM;m{!B1- z=R&6f$XIChpbf{hyaH$8RYV<$099W8-gOu5MoE7m?jwNwHSb!8PoLNYTzuZPp0D3$R(ar1EasATkYctB#z~H45 zQ`awF?Ib5ft>T^@HcX;HiAl_{(sR<}sD7?i8>@guOGGHZIw^4$3uVH9TRf9n>r~nj z(o)gRvc-pdcSBsS7a@T{Ij7gg?b=^S4+M|~EP-|0$HRoI znY@*6d-ag+?8pegLrr3O>3la*cWI;fD9w3XB?)qT2ZM@EW*|6Kbt?h1CUipJ%})0Z zoH_X<22anf_Apm5-hDG4^j~-eVl^XU`+_v zB7BOFHAV2`=rx=_yQ)#%1go1(eC^7c)7P$^$sz*7lpLESPpB}!F6~8$f{Ifg>sn}u zUvLQ*@|_wwijrSo4VfE_rA>beFu*k zC`7iwwNv%a)|&HHL21^=F8>7}vA)^5wkm+YCcU@`kVw#x&cKEdw66gZfPxw9>F{y( z`KNz?skbK9;&@k={H8CA+&FvkcoxN?HU*G`(7tlP+^XxdGW**#FmDDHTHjNE*qM};6$L{iG;OyAmL ztggYRYZ)!^BHla%*Tf6lno^fLqkN3zBXsV(cQcu2PN?U*fwup6>@ z(K657qU+|?@E(vj!H?LrCli55s%ZcK3}OWlfl>-e46+nqo3C2QxgjiQtaiciNmodhgdeLGS|AAB!xZ)^GfwjJy0d;EPLpY^gw0#O^Z>SvjQZY5*3@;-_0qmuu% z@-X8p0_ABKSa@WSiHg9)@WmUaUj9n6+f&Ym)hEB@tVB>sBaDCu8O%s(&(4P)|4AI&*}dLltisNt z_wRY^U60S|4p@~2vxiciStGx38_h-SJjZS=A;1t|N|g+*rq~K06bm5>4TGJY!sNws z*MkeEo?FXduZ?`l=)`>~u#Arg&- z#J8!j##dfE^@d~OBUjp{ETF{o@HWPRe1@+HTRlB{>#Z|Cz^&6a*K(L^Lw-|ljomtV z>IdP}=yb+YkXUuYN!u6zHooCX&cIc}LN~?AEzXvtxFsO2zF~n0BQO-eDddbonC{$v z-;aIZS8?CF4z2en0RRu}eBj7C9{<>+q4cZ}zzQ~M=W@=)D$Ylpxh_?wQnj{`76rC( z4{uLF0Ax}Mv$t=bE1W(1wY8p=H51M=H?BNa9J=vZrxfAYsKjxGsAOrxGqyGZYTqD@ zVF0j#u|-&AU?2!YV=UCzeeB&&bRT=qFXP^I_x*A9@Poh5edK|6O$Z}2fbvqV+uYh+ z)|(si+(TR{?4?rl-6mCCQ3O^DD$Jq>7DMMw{|Im2ys*~8U%R}mDV>|NGhO#R{_YPH z0-a*riM$~g5L7nh>ihB$n0=@1w*u6aEw3ncuc!P`DX~-10<6suL^U-x(hTHeUp23~ zU@(}{Fh$Dl-MP1W;KbQiF@E{>8V=-o3$X^7{;8iCRp0;xOmR0=w_c*Chuy4P}FE5u?~Uk&EY31cvnXfPBAu6dc! zfYk&*!?z)}A;ba6Uq1PV|Mj;p`qGWHp62?G$W^%>`U@Bx_}=)1GjDG97%B|l`#zu) z!Z3i1BCwYRSph-Sy(nAlVr{g%+GxwJs{SKF@zMkUB7oe>5~Cm~Od<{5b@(0K@Bi@M z2liywWx(B!^gi;7zw)oKv{+WFWx$N>N_0>Mp$xy%{%+p5)ctQ zRuF4TK7-|2L{%4btqOCK39F@Uo@T;Ixt}N#tP(_QQDDKWr~LbV>;wNJ9(nBD>oT^- z9{t6=KlY)&7pN?S0U%ZZ%9TyF(!+7dSnAMQ?b55zP82b)5&?){7!Y#SAc&#JAWH%K ziJ|kiUViRxR$IEN5!Ti@a`tQxoICgA?7)=^TXYdpoB~S(t$aWmrKNS@T3BxofoNDWvI2tAE~pa8Rd_x*=H_VHiC)}wpZbbx!_z5mgl{l#CqVR+kU z-hx#Yuucis8nY-x@K%}YyAq{-9dY1HSyw7fh?_ziaFoOLl)>nmFTa6XSB|gOP}klw zJaqBx>nEOjZikML)dnPyTWYBkj44+Z)LxvcdBLu9yLa17M5Nr3#gMZA;iB-O1c67G z@zc0z2s_^SzMsH5e*EXLhQ8yi@Bhhv-23Qz-*Y1mbplEwiXwpG+VOhWXoR;B z4A|NgR^|I55Vo*NT)PUm_mnm0Lx3&$k@GiRd;SZ!^~&ga4S(&)Z{+;+?8`5G%?@5W zu|3W3#ULsMP^@8@VVM>bqg=7GxwxVLF3R zt1;>K?S1@Xzx=D%`@zF&GRP->>AgpP`se@2tvvi7)eT8?AQX0fq#!`b^TEP%{q-B@ zJe0@0-UC?S*ohzt?gfsExGYi)j=J3=l1O%`RK3PeTN4m3RMZV-aXr zFpwxV3!n%>6%@k;T`EF)WMJt055My#7&*VLJz>@9gzngiVrXI5x&^mbkf#w83NBTrnb>&GuGPl^4|G*3R*0{c z*aw)E5Pebz$IhPoE-t*irlo9k$GL8M)}6V6-?4w!kp~{jr#k(77&>QYCIiI2zgohZ zyELoQsHVmbG3Pst>O0#c2%xlL0Z3rtsbY!e-p0L^RR*>o@VyL76e5-8p6z?K?i{*w z@dZo`Hveh9?VXQ5_N%}4iNS2&-XY_GY#OW-A__~XIIsdH4-j_!hpQ7SV;#oQ8;jrf zx?C13+-YUC)N)=6ZokewRPk@k3=07~uhhXvvqg6>i9ONe`ICSC-+vERkDpzSnXSb7 zrNWP#pPqQ-rO%sz>nA!T52Y=TQm}>Gfmz?3SmC;}_P*~u)V=qoKmO0m)_spoNeZA8jA95S{lScT z;Kd@QCQfmlv$^*9Up-V2p}0ADp`J z((~WylR}}33rH*HSE2>h8kNKs|E2UkHL~n#e%r=;sIUO6#9iDN3JS^+*cd1yAOftH z!k7@ktve1LdH+ZM366gBp_T5s66%5X{?xwr{rJy~at6gf1C(}3AsQeUutZSCfSj#S z(JHZGbFEB#Ej+XZQRiMuvNgaAwgOKqD44&0h8{IZd-L ztp_^~-}^{*+qT2A`2eI-fGEgPNUCOOyHI&s8?~r@ayzHBfIx8@WtXZT1P~a?Dp)HZ zPs1uffB+DS?Oj{8iLqvSaO62m-wszitb^}A@c2La=l^eh?~zAu770eOVCBKEQ=W0; zX8{mM7$R{(*@9JGEp!dgqTgJ?HrIzyOGXI^w9+7J;M)*foWOls*xdg9*S|FJweNij z7+YEy&MIO3dooP*kB^1v;ND~Rf6(f5X9$G|sya&40JRk$(jbK;ezWmsZIluF`#^xI zN*jq8g$N`9O9ZQcNJ5aO5d{I5JiTpO&$iolZeBwF$@424$P?a{pZm%GZQlof>X!$Q z_H)R9;e1F|#d(Kf0U-$3^?^+mGL@H3U8`?(u7T!lb>op=+3;WxGZZrr6_K$89Owk- z#<|zu{?cE50wb@lUp;sYT0#Co@aB)696x>XJNr7bMwt*wY+WB+Rl}jhVv*%-ZB!sh zR}fPY0uXX-MlA_KL?BOKqyR)kC{hre#bBh6@7;6c9UuSr|Bi>>zp~uFM|VH+;7|VC z|8ygi&WQ-H?7>>V(>hj45vML*T)qB;Ta>XS86zjNP z!6HQgc5*m$_L*;g9&bPU)_NaWCDz~a1AzQkaiZ8UvF(9JpNKkpcIFMi@I1Ae4Z3mF zT7Y#CH%o4#0SG1XQ;M?3EQ*f0NI-A}yx=dR{L?cLg%>E&ws00 zesQhiJog^}D5YSc5S|LL)3=y7`NDIdxEJ&Qvh-+1ZiZ|~=@ zkTD^Y6|lIajga#uzW=8_)<0Q)<-^Y*#IOp zFLjR>_p87QUPxY_& zan+(VEI$CiRFt2XxOL#3{f}jK962~E8i6T-QwpM8#8FWS6cIeQi5GGT(24*ih6O{C zg8ccOeoeCc)=!6TH9<(~l0*_|KLNxALCVV?0$9UFk@NGw^)pt?5*kHmzhg&NN5&gJ zedz}%4(AuW`$Io|-vj^R*ME1QYx|=)oxyb8LTewS6vD`qbr+NW#c3{pb>|+=2iWk~ zTEXgDP_J*HQ?x)H-wME%V+}rns5p-@sn{SNAP=yNR4R+8kOvcB$-+x{U}EQoH6gOW z3?9rF%$|Dw$vb~>{Qm*^TduEO3y~jyiQ6N2@xnuoe&_=^Qk|66$Q5SYQp?YRje;@) z4Tg|~;mAe(Vym!n2~_tYsL27G^YIdgd-o7t4U(I)lq_7*B`+N}C1r1=$q?80jX;q& zr}y?PU7fe5hHm20iPMYQ@q_->pZVE;(ea+2{D-6Io>U z<@+kJ|Ldbw&R450Yhyjf_}mLusN|4x79SW8f)NXXg_qJW5(2RX28)3<0q*G_Y#F=y z_RF9BkspBEWbV4&dd~w}ckj7>*yL5z(E%Y32o*dikQE3;5OODm z$f-dKxgvZ8c4@9_Wcl5lNRF#c_cNemeN}$n5h6RgOIe5skxHj~w)SkzKGc zxZrN@fA@nA{qjHi&0OE^d&dHcFp8j*0xJcRM0Tlid^f}Wbyld^;NWJ zEU4wk4~6mK*l6L-uA@gEM(>vWMWtbo1_}bj9>iJzAt)|pH?e9gguueFCW4)l>{>z- z32U>GU*+*v`&3;&7S97B8=+tbTl@OeoJ+~vM~nJpjwm4CDI-9Pd7 zZx$oWgb|eT;H6RsOav1NNHLfh*4B=z(fAkp{(8;ta(}*xU8bS(UU1XO?!PXg;Tb(aNC@pDx zv@D$Qrva-0s{kQa=M8~10Tfy1j`Dn{l#ehhf}%WnREVyb;Wy8H<4d2!l@pVd+iaCb zPlDF7{2Zh(6<&?9oktHId-%~=>!Sd6*Do_j3>ZMjR8|@hxYP^~tk_sUadmO`T7C@= zxa#j^;;XF?5s>m>RSNm(9C|uCw`a4d?y1qiQy951G-tc}9@~5DAN;SsqmI1miCYCA zVg;~*2q84a<&J3`_YpR8#*13I>?n<8B|l#wk{W08)te{sH-Yk)4WK%4xUd!#7mcQqyyM>q_D0+E6#7NLnDkqaB$a`|1B>_j^D3FGjdSPecq$P6y5!>y%_v-2cIfyKlu-1+Qv* z=rukst#ZCvci9U7LIk0>p4+;L9%hS3Sy%>Gd9Vg92%-oQ=8)AP4tl`&spDTA_{v}W zTTH)HT)X*P9$ItRo8amTZ@%``&;P-e+{mSEI)at}kN_YL0*wl(Shy+#z(|r4*c!{6 zR=M4pdr*l)l+0NIbFANHLyMH>Lunrcoy9^9(#Y)wt9CQI`U&b`>T_x zD>arcfIW9fveF<=16qTaA;!Q4!IB?1o96-5L?q-&77Pm|Hdbf>pn%9&fE2V&BSi$5 z9JXqM&gr2OSHAwmKfu6=g{7HV0Ih5JIf%@v>p|Mzd+34te{3XQgz>V7EP=fg#28R2 z4FmxIftT`OjB!@q3NHGpXy#@VMwn0SI;oJUnU~U6V*ebo0-^v>P=t^V-%Ft|Gt;|& z=T0?r>dZ3;M)JT%bms>@@N4&cA_ff`+)&6vaUfr2)h6bOs(X3gg4rsskKMMZ>4Q{Mr8=K7IVFz;H_l z7c7KUCqJNYyO^6ST*$^%#}1Fp6u>%-D71j*JAaQ@K*Xg(G<&&QbCI+>`6W;2 z)j;J*|KYEXWw!61)+t0B zxqLq8IKqhfH)usu__-f7udHn|rn;2$A&9`n5ueL@Cs>vE*fl)G;9>#26yP2|!pLjS zJvH>jul(OAoZb*tAFvU~4*(`^jE>}Z=*UBlyi0X-Z67a2uquU&@(@l;A(crx-$PIU zJhkG$Udt2Qyn}DjHmY6?RC`PBOZlIKI0@N86oEYt0!Cy5C?;gHsSZDr?VFyoBY6A+ z|Ln;7e&WY(`t6lV0 z;-DiDLyCE%!W{N_5xNJj9Dn2UpZZN)d*SNZ%wKb`F~|=9ZUr}|)2jQxk)sb3mERc{ z0SsUSP>Ny6X}p1Hb+Z~ar7!|5yi`K@q2YNRthky`Q3_BDSPN?-Sg~Huw(a{SJ9{78 zasMM9EM&HHPl8b-ad}F_0BO`E2&vvrqqYWZ@>|Nb>YUg39wGst08tE9E`gn_MM^VN zVH$hWf^E5x%dda&FMlt5`EQo3gZ2ys?kIoiDLU?#65( z4>2KBCIzuc^Y>M}(C>2cn``+dNz64hsda8bB61~YrHE{7xiNuY5sAQR-%~wX_ee)i zY9yBjrL!;+xc~=I#eM)Fj`)gzWB(JAOK%~exEFR}i7HfCigQP|-c#G0ajcf*m-tf2 zm9eoRu+lD_MG@EnN+M{RLq{-$hr0F5sjq$hkEXuy)E{jWKVKy_5cvU^x|yGxiY|5S zy654Y`}Q9wkj7MKV3+`3Lref7NmA2SXAE~a@i{aq7pq!pyh1oAU5v3n0n0g**{=~}HH=T_jAl4Xfsb9%PQxoc7K1Kfj0;!;Ht z2%Mx@l48U*Kq{KXku4hIufOorwXgiuCop|_U0YBt7aNQG0AS?G@JN&%-?snIgVeWu z=SZ;#&SarI54JD^Pia_L8TrlE4Q_KHvHWW0q0UXn;_5>+F^FTLGPd+%0zsHzNx>L_ zpUonQqWEK%%Lig7StSyuTS#o_NvyMw`1~r!Pq;>eVV>hyceEn;l^-C=#=4lC)Xc{p zG2nBEZJt4DeBeiKeEJW616Q8kP=3BjY&`MTxTk_+)o6E$&aM0bG6x%oGYIF96vBh zp`em+ObxfKl>#$?NP&pK#Ly(jaD+a|VMk%?t=B*GUq68pUw?7EXL2RjnB<3v$lRW| zXtL>D`}W`Sg!O#R8-h>@77FY$!~{U1O=cJ#2ud@InFVxG8_ZyMn-(nB(sWSS+*{a4hygB(6VBe2mTx zpi+7ZG{f2=yi5wgOdg(}ft3&{n8DsufTJpZ=lMVSy?=)vUib#E43+K{!UiQj0L9^a zZgS$yj;%ZP_wL+x)T)#UEh8j?gn0l0QYk1f7z`s8p(#S8eJ8jW>(No70Z3G`{2cMk zb6)3L3U7GgYXTCYjQ>t^2x&BMZ^#xFjy|7a6Ar%@#`Blak=1 z^B0X~Cqb`fqVig!ZVQ3cCWpJ5{OT%)R|bX*K-NXW1q8zcLtNp*llXaWs*HL-!4m}u z2!1Ms5Jd=b=;b1gq@-}}Z~x|xhoAZO@8J5Ig{6$QRk5+j4*;fajorC1dSTb0!$-Py z?LIgg83araD@d_I6c%Agz^N3hjUD5n;uVsOuM`8oK*hd6VOfNA5o0why$w!$RX66k zSjKlZ`7N|-ZN;^DNXj)_n7u6fA4`@=oEs}}%18_wA^|0~D4**6l-?}|Adt>)~hmU3ZdiPBhEldzXF~jpbL{{P;h$`iR z5dbCwCL8O(P!b;j!1I$|JwIRzM}^mVG2PkH6(Ip zQZp%lG>Cw(FpXVZKK7>qOrCtE z$2zub-4+-@K?J1JFhXFGI1$<{casPr!E;kV9ETj2cui80S5x)xy8#?_Go#_Z|N8_=A1e)7LC&0BlkX->FwDv zl@Abskk)Cy3YZll6FYu6cJ#4^$3AQ^bY)p9=>B$h_VZl=7Rq&`itkmu-=LCY&9V06 zS9{GY`r4_H7b6fjDeq)J*s1GSwh$0FRz3Gs3Lu6FipXSrM7b$sJ;A|Fjr5JTUp?^` zfB5h4(igwC=2O3hxC_Y-0Qzk-Hgx9pbeKjd;))vEEN`3%xqXF#jD>tu?t4AJt#{=Z0w&bmX zO=V%BTzM4<5QR{Rp@|?N(2?yxWJB1Mkl$u6i(9BNW-Z8XMRC*(vh1z9Ylar*#t|)W zQY-+-y-&nI%BdgG79S!7WWy=!umv0l@)uwE%Rl~)^8HVL8JnQp-Q7!m05CRYB+n;q z4i9<{JoNCu5N3dZ8u@N+D*vP_K6E{X`Qo`~Iwy|L;b0Okc3-Vjh z(02FQ)?bPgv+Y?iA^#1#f>9pT%0fdnSl;@TL2uHi$shc-JpGw30NBt%1xv-|AU^<@9SrTc7hk-Um)Sl0_wDc4wtZI)K4!omoq-X7 zMFa^0C&5&KHi0b>^DNJ(q?%I)cQg4dw8`Z@aOcdC0Lm?LH8dCsRw)>*0iOW2=s<*R zQ64+ShEKipnLqw*dG^y^#*&F|cv)Hdf^iJMBe*91W;NQuQ{`~K-34$B-u<6JTfZ*b*XGUja@W4HL zj&AMk+Fb~Wuv#IsF8PxY048vbW95}n1V98$3S#!JEK-b1FO%!W1FU-h zlK@NtW`U3aBy#Vw2WB5c8f;y@pXa3k&4^&&sSsUe21il`-M25jbo|eL{}VX=h3~HJ zM7B0IBl!UU%+-?@28L!Y?b^S4Pw%#^_ZEsFvCbg024XB&d0?#})&Rx;N`aMv6)9!P zyN+>E!E3a<^0*o-cyHI~nY*+NtTR?RzE(fJeiS%`Qmp^efE>Tj#t0-(t027dSla)9{|C?*{gSM zj-Bq?x#!TKgNKidjEzG184!0Mf(I$&00ufbvoKK+HlG91K15kqZ6G>=XbW%>AX61n z+NcT8!dUdZJx@dLmPey;E~dnAp<`?X>$mQ4)f`3xgOS7o!7I!2vjr%ElV5WUx|p2& zl>2_%>x4rP32a2Jsv~V7n8sEwkB53xbmiM${_Cq>{LA0K+3&u+-qXd5pw@W6dXXL5Nx6Dg2RL1_g@h^SZqAQ0aNU;$NmK_;ru$x=6Q=&!_$GLKKJ z^3}pJS_@*{@iy^T=5rq`Yi#o%*)DVSHX7`&?p}leXb8t`IFkwuips78R*v|@T?51f zX6y6`lnZraI((R56ul~kNBX>}lVADEPYr+Ti@$+O&);d@wM>9u zxyRX{Fu&Q6#HG&5QomoXd3vR?vY@Vax9+Bh9C?DkVqF6wvE>ah0ZNyaxJ(4aZi#FZ z!vbK5fZRCtWGxQr!i5(;_1}KK_=C^=4z9d4+uVuW*l3eqg6aO;%%#DX3fh>yUAqos z)9F4PMF@isUZw-kK8j%xP}&h+(mltx)>fPR7TVmB zu8FeGI8m5%fix*Kgi2(;6cm(lC2InK0YOQK4&-s5L(n%heD?UK{`>FZ;PyLozW;KtQ`ySDfCZSOlg6%z3gyDWtQU93oPcPQQ>#eWosrMML>w73*^D5dz~{@>)E zyyWHOC6haMW^!_F?qnwSe&;*i;e1?WPcup2x*UFxPH1E(3Ai#!sQ>xlb@=hjG`-W& zYm)TUU*=Py+&f=+K;l8@VmGZ4$xZNgAu_^HyN7H&T9e`O{eV>a9Ldq~Kv_F25^ZN@58+53DjC+NP7*PYz$@SBAhxr(crHY{Hsj zy~4Ye`wpUC|3VdHak=iSc3b>RA@p$KkI)_)exrPw#YNoLh7AuoDcHmHMG zIA3O+I}mP{;h>AAHY4O9EPf`0+aD2hzE)1x4Z^DvOxc)cTA9^Ue;Y_U)XdMYH!Q0c z^Q%pN$|s;Rdo_B9_EWTZsBzoes?p&Ik4*=T{+8IQjtW9f3N8SSV6kzZ2Ah>b>NE1L z4WZ#Q3Z~a3IS?~2qv<4;JVnl4!&D?*6O7})o=M&$n$QE~-{VW5u(lgVC{7}Y7xlC)mDoI6nJ2rO6N1h&-?oC>&*|Mqc4u@PYiy2e&3G0_ z@yu^Fg0ST>q5gKK=SK@P^s-ppo26Z^q6TdSjN*Lc%3O2}u!$rhfv$j$^5Ql_MrxoS z-N;pM+Ne)66CiA7)}Ay-Ib5@xPE`7o^>a`a1&9oGqGIo@&OxqB_K`S8lY_)=O^f12 zJcPJ$Z0hLv(C^~ZIuO%%w7w$uh%C}k_L4kK@=BPY$As8zBUFTpl6-h?xf$WxAXraK zFf{Uqh2xS$@h{7y@xPe{k%-HnX*#DGrlksBh%P*^b5HDRbMXgP_;=c_8Btm4P*PwkUXlYzDTJR|u!?8|J*BtL8dkqH?%_~jzQp>{eW*jGetEKudYq?|8zI}fx^-XcZW17kD7>xhA#U;Y_4Oof zehdO68ORf`eJ}ICpGsyI{>EZ1f+_b$HP`o4DhK>hY#*OFITi*sTdMcgqwN~O()}Pl zxIG?_I(4|v&~3KLtx0VQ%ZA<3d+16N3HU=R{cvZvZ-0Qn`rHCb$r<`>G_x-^eK;}Z zm98&;yw|N28gLkZ?Q+PO>*aE_lAJLB11xrE`-%a-nBi3O5&5s5Xp@cJ3#$L!bG-_9-ix zt~cx9wdVMviR6^z?+&rGQ9FHSpi00d?4Dh*LhCt_sPFML0p1T}mU2Tj^C#)Hl-6QZ zg)x6r{!?J;`=w+aos)pT$5=w=ASz$(l&e1cew9PymX?sVT~J2$weKvS#m5L~fU7}ThOidIwHe9H9+Oi8yK6tNj=i)z4Pj0_QE zZHk6Nv)Sc~kY+MUQN|>`{^lM0J*bqi&mxx=PNRCoO^zYddR7(^aaI<4?bnr2vD)}a zOT69>No(1AY2+}dVPTnc3%O^neKjD#Q_Q@jz&}VD7H*1rHLp>iWB~-1&T$jPDvCjL z)a_~vM5(ZnP+(cn=$mUGq&wQYG4OPWSHyoyB!Kt&9Gkp=@S3l0)=|a=7>FGstCoNV z3U}?yp&MSFr|*%TsQm*QM_D`s6%)QA?p6u)pA*<`9O}4dI9;Xt=$IU>%1(jIHW7+GHBMnp*qs|W8=RzlSR;7UJWP> z_l{}#dSr`UPnDT`yy4cE)X?j{Q1|K%w;Q(y2i+a}!v=XEPx^7kKyk~Nw zUiWf>M^RlhBS_+1KR?@-K9r)p^S!#59YO*C+m3qV~06A8_;52kd` zx3(bVQqC><)5J?S&GvNq_EGPzNs`=hs#!E`ZPZOe?=CFp>+`|Z?3RN11pihf1vmKR zPj3FmaI=r%V_jq)N~0nhk>gqH&_Hac|Nd-RP{MEME4;jb=rU_|=?2P!T3e%txpXQ-0Z2;WTMch#coe?)@p3TYmdXWG*3(2W*W= zESp_-Z>g6xPNl&k!%aeuv^=>o-&hugE3IcnXzi8gfUlCUwbO8;KMo4mI^o_x=vv%U zq)@DZ=V}?bSNCs@=b82JGv+4@9m6EAD!I?0Q;|@$mCl^Es0H|&8XZ!i2QUX%H?ucY2hLDb(bGtPZHoGfTIKXA^<2S_bmY1qHK<9J^+l#q zz;IvkTZi(2YHgQT9tCfZO${NCL4d;66pu{CaWVbcfV)(r!4?rqm}fYqiOYjm0LT;) zp9jSP7Q_`BqNX30A^55-~!N{r=2&trOtA0n}$aQW66 z`|^G@vvml)CzDGMZW0Lwz`BJ9`pE~uhP@el{_Sqf3Z*P%X50om>W!$BMV%dEKV)Q} zfe$#WS-zX*iaW51@yf_LU5l2Q&wTJ8*J4}nkvYA4 zG;12%p6ALi^7Y~@$W&PDPoIX`Liu|yWKawr6tFTJ`AWB`+o z{BvdJLb|%C>xvL1n%r_=O_+L;N~6f^pGQ-@Ik7LQv8hc1%)A|60{6EL;h3RI65i5y zdN?-J?r5{pL3S>G-z9wLcQ|_Jd)j{32)$t++#T`Ir?aaBQ4$u)Mb)^=I&*;?**|`NeMhhUgr^mbF>mU8nbR^y zL}QcOL^xo4CvyQAr0ou=SD_L1JcyJAJTKeKeXm5loe67Fq_0p6S1BqEf-adkZH?(# z$y`jNZH)SP{X5I(j5Ti*f&HtjO|uqtev0pmapY!^K@(bDboyRI2Ang>Ua{w6Kw7P&`Pn7cC*hcTdmyZ;1|gF#U@-$cJN#XuGLflYZt za^xU9*lH383AU#Nld4Lo3^0^LoHX97wB7d$j*esp?391%@SwiC3*WM!ajsyS-<-(= zv0NWVLfE|jZd0^vLlwq-j%L?(Jbna=mA9K+@-eY@^8*|{g%zdlu8|-t+&na%rj<>p ze|pSAhIkiqLcDSN^dq5C-$Mnc^HW9g7L3z?fBS0K+P@xXl!^>|LKY#gy*|wh8(NR% z&f0v*!>?FmnegTL-iMk!0nsQNBrZYFo3+n)Z7`w*(;6fEr!qbx6jq%s``OHOD_tq-L`t z=Kxc7%RdKjk>#fm)qW?DL3;LTb@tjA@wdPbx#{YIf!xpZ4RCPuEivWjY|TQerPj9` zm_lhndx}TA^rOM6`n>6M(QU&s7ir-DF$5INsB=lxqJ8CLh> zCkRybEr+T|Zr8L_WX(=#@fmgnMjpy5CpUGKP@o&M?b0^R%Y0xab;c}2&ANLqd$Res{J>9aFWEl2W1nihSVGDAUJwTS z*YlSyNGOkyy{5$vN9H3Ti+An?rL2N8;_^ZlRseTnKo&Y8e9E;q!? zxPlz+H+t23t6_q+7vk*Cwr$yfRFFKQAH3b~0^$BQm)ly$Xj9Rw`WUIz%zrVmRE z;UIr8pEeL(M<7Gt3DDLCQqb_eo_QT&1R_QMGClZRls9!(cg|byAn-LoIYrcz>Y|+< zU=E0d$clrQpGSvBgfrl?hou^*^N+ltCLbZQdOWn$5%7LC{oEa4RmnNW{|N%I z2YZVe_r%0#HN~0Vhp--}eam^3EpM12n?gV%lr7IOI{oV_8dRQMIZ~uG^(Lr^NM>YI zD}*8KH(%|7DwZ}qQH{Pw+!s+%YbM~KkYbZlEfR+|6{ffl@R@9~7Y z`E6bgyQkc3TQhf_UY7$|WfGH4>g=TmTO6?;#V~ICi%8kAW=93tMz#?aSu8xMqi?TYTEhFpT0XvOEnrBO4B_;lEGMkZ2Roy~kK&+Ty^hn z+ASBnSoW}(Z$@J=D)wM`n!`#hL$}yB9@AhpAz&lGjlBhv;8N)qpS0y)maL6J`9VCFyrI}^8zk1DQpcEu&E&O zMK|Zgt-cptr>6&lPCLsv{|=TuANYC(_(lx?Ub+Rsv1*n&Lapz7Xo^>3UmUThlq*Trv8#n z{M|VS&e#t=^vd)7A$hsuY$cg>HaSNjlD3bLHOS#tNHOB?eDU71r~+&q3gxpK)&|-2 z^)gV2C7FWZj+NYEq9Fidm=hOVh!H=OPaqSkxgQGW5TdgSg;&8sy5qw7J}+)gk#)N; zg?Ez`h(^xMnbE~e@-f(f>=>Xt-;1?~DqmZUt7_$8hg3npq?W*^{pMN7jdIvjKJF9 NXsGBaWBCsu{{zlAQ9=L! literal 0 HcmV?d00001 diff --git a/frontend/src/components/feeds/tableColumns.jsx b/frontend/src/components/feeds/tableColumns.jsx index 4c805126..a069ea15 100644 --- a/frontend/src/components/feeds/tableColumns.jsx +++ b/frontend/src/components/feeds/tableColumns.jsx @@ -1,6 +1,7 @@ import { UncontrolledPopover, PopoverBody } from "reactstrap"; import { FiInfo } from "react-icons/fi"; import { BooleanIcon, IconButton } from "@certego/certego-ui"; +import { INTELOWL_URL, PUBLIC_URL } from "../../constants/environment"; const formatInteger = (value) => { if (value === null || value === undefined || Number.isNaN(value)) return "-"; @@ -121,6 +122,33 @@ const feedsTableColumns = [ }, maxWidth: 60, }, + ...(INTELOWL_URL + ? [ + { + Header: "Analyze", + id: "intelowl", + disableSortBy: true, + maxWidth: 60, + Cell: ({ row }) => ( +
+ + IntelOwl + +
+ ), + }, + ] + : []), ]; export { feedsTableColumns }; diff --git a/frontend/src/constants/environment.js b/frontend/src/constants/environment.js index 3ffcdda0..87b14232 100644 --- a/frontend/src/constants/environment.js +++ b/frontend/src/constants/environment.js @@ -13,3 +13,7 @@ export const VERSION = isTest export const PUBLIC_URL = isTest ? (process.env.PUBLIC_URL || "/").replace(/\/$/, "") : import.meta.env.BASE_URL.replace(/\/$/, ""); + +export const INTELOWL_URL = isTest + ? (process.env.VITE_INTELOWL_URL || "").replace(/\/$/, "") + : (import.meta.env.VITE_INTELOWL_URL || "").replace(/\/$/, ""); diff --git a/frontend/tests/components/feeds/TableColumns.test.jsx b/frontend/tests/components/feeds/TableColumns.test.jsx index 6aa89dd8..9c4f95a0 100644 --- a/frontend/tests/components/feeds/TableColumns.test.jsx +++ b/frontend/tests/components/feeds/TableColumns.test.jsx @@ -4,6 +4,59 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { feedsTableColumns } from "../../../src/components/feeds/tableColumns"; +describe("IntelOwl Analyze column", () => { + const row = { original: { value: "1.2.3.4" } }; + + beforeEach(() => { + vi.resetModules(); + }); + + test("column is present when INTELOWL_URL is set", async () => { + vi.doMock("../../../src/constants/environment", () => ({ + INTELOWL_URL: "https://intelowl.example.com", + PUBLIC_URL: "", + })); + const { feedsTableColumns: columns } = + await import("../../../src/components/feeds/tableColumns"); + const col = columns.find((c) => c.id === "intelowl"); + expect(col).toBeDefined(); + render(); + const link = screen.getByRole("link"); + expect(link).toHaveAttribute( + "href", + "https://intelowl.example.com/scan?observable_name=1.2.3.4", + ); + }); + + test("column is absent when INTELOWL_URL is not set", async () => { + vi.doMock("../../../src/constants/environment", () => ({ + INTELOWL_URL: "", + PUBLIC_URL: "", + })); + const { feedsTableColumns: columns } = + await import("../../../src/components/feeds/tableColumns"); + const col = columns.find((c) => c.id === "intelowl"); + expect(col).toBeUndefined(); + }); + + test("column URL-encodes the IOC value", async () => { + vi.doMock("../../../src/constants/environment", () => ({ + INTELOWL_URL: "https://intelowl.example.com", + PUBLIC_URL: "", + })); + const { feedsTableColumns: columns } = + await import("../../../src/components/feeds/tableColumns"); + const col = columns.find((c) => c.id === "intelowl"); + const specialRow = { original: { value: "evil domain.com/path?q=1&x=2" } }; + render(); + const link = screen.getByRole("link"); + expect(link).toHaveAttribute( + "href", + "https://intelowl.example.com/scan?observable_name=evil%20domain.com%2Fpath%3Fq%3D1%26x%3D2", + ); + }); +}); + describe("Feeds table details popover", () => { test("shows details button and popover content on click", async () => { const user = userEvent.setup(); From 66c8390da10f6817c80095f7493a677e4056211b Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:30:07 +0100 Subject: [PATCH 005/109] Several Docker-related improvements (#890) * add folders to .dockerignore * refactor Dockerfile * update requirements * delete unnecessary files * adapt compose file * fix minor inconsistencies * add default stage at the end to ensure the default build target is production * move nginx healthcheck to compose file * refactor Dockerfile_nginx * clean up entrypoint and move start command to the compose files * refactor health checks in default.yml * conditionally adds django_watchfiles to INSTALLED_APPS * add bash to ngnix image * use curl for healthchecks * use explicit filenames for copying * restored waiting logic in entrypoint --- .dockerignore | 4 +- docker/Dockerfile | 69 ++++++++++----------- docker/Dockerfile_nginx | 17 ++--- docker/default.yml | 38 +++++++----- docker/entrypoint_uwsgi.sh | 13 ++-- docker/local.override.yml | 6 +- docker/watchman_install.sh | 20 ------ frontend/vite.config.js | 13 +++- greedybear/settings.py | 3 + requirements/dev-requirements.txt | 3 +- requirements/django-server-requirements.txt | 2 - requirements/project-requirements.txt | 2 +- 12 files changed, 92 insertions(+), 98 deletions(-) delete mode 100755 docker/watchman_install.sh delete mode 100644 requirements/django-server-requirements.txt diff --git a/.dockerignore b/.dockerignore index ae2cad21..55321011 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,4 +15,6 @@ frontend/dist frontend/build docker-compose* .pre-commit-config.yaml -.ipython/ \ No newline at end of file +.ipython/ +.github/ +tests/ diff --git a/docker/Dockerfile b/docker/Dockerfile index 50ee8247..a8ac894c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,8 @@ -# Stage 1: Frontend +## ------------------------------- Frontend Build Stage ------------------------------ ## FROM node:lts-alpine3.22 AS frontend-build -WORKDIR / +WORKDIR /app + # install dependencies first for layer caching COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci @@ -11,56 +12,54 @@ COPY frontend/ . COPY docker/.version .env.local RUN VITE_BASE_URL=/static/reactapp/ npm run build -# Stage 2: Backend -FROM python:3.13-slim-trixie +## ------------------------------- Production Stage ------------------------------ ## + +FROM python:3.13-slim-trixie AS production + +ENV DEBIAN_FRONTEND=noninteractive ENV PYTHONUNBUFFERED=1 ENV DJANGO_SETTINGS_MODULE=greedybear.settings ENV PYTHONPATH=/opt/deploy/greedybear ENV LOG_PATH=/var/log/greedybear -ARG WATCHMAN=false - -RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/uwsgi \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - # Build dependencies (removed after pip install) - gcc python3-dev \ - # Runtime dependencies: - # libexpat1 is required by uWSGI/Python, - # libgomp1 is required for model training, netcat-openbsd for healthcheck - libexpat1 libgomp1 netcat-openbsd \ - && pip3 install --no-cache-dir --upgrade pip - -COPY requirements/project-requirements.txt $PYTHONPATH/project-requirements.txt -COPY requirements/dev-requirements.txt $PYTHONPATH/dev-requirements.txt WORKDIR $PYTHONPATH -RUN pip3 install --no-cache-dir -r $PYTHONPATH/project-requirements.txt -# Conditionally install dev requirements (coverage, etc.) -ARG INSTALL_DEV=false -RUN if [ "$INSTALL_DEV" = "true" ]; then \ - pip3 install --no-cache-dir -r $PYTHONPATH/dev-requirements.txt; \ - fi +# Install runtime dependencies +# - libgomp1 is required for model training +# - curl for healthcheck +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 curl \ + && rm -rf /var/lib/apt/lists/* +# Install python packages +COPY requirements/project-requirements.txt requirements/project-requirements.txt +RUN pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check -q -r requirements/project-requirements.txt + +# Copy files COPY . $PYTHONPATH -COPY --from=frontend-build /build /var/www/reactapp +COPY --from=frontend-build /app/build /var/www/reactapp # separation is required to avoid to re-execute os installation in case of change of python requirements -RUN touch ${LOG_PATH}/django/api.log ${LOG_PATH}/django/api_errors.log \ +RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/uwsgi \ + && touch ${LOG_PATH}/django/api.log ${LOG_PATH}/django/api_errors.log \ && touch ${LOG_PATH}/django/greedybear.log ${LOG_PATH}/django/greedybear_errors.log \ && touch ${LOG_PATH}/django/django_q.log ${LOG_PATH}/django/django_q_errors.log \ - && touch ${LOG_PATH}/django/django_errors.log ${LOG_PATH}/django/elasticsearch.log\ + && touch ${LOG_PATH}/django/django_errors.log ${LOG_PATH}/django/elasticsearch.log \ && touch ${LOG_PATH}/django/authentication.log ${LOG_PATH}/django/authentication_errors.log \ && mkdir -p ${PYTHONPATH}/mlmodels \ && usermod -u 2000 www-data \ && chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${PYTHONPATH}/mlmodels/ \ - && rm -rf docs/ frontend/ tests/ .github/ docker/hooks/ \ - && /bin/bash ./docker/watchman_install.sh \ - && apt-get purge -y gcc python3-dev \ - && apt-get autoremove -y \ - && rm -rf /var/lib/apt/lists/* # remove the apt package index cache + && rm -rf frontend/ + +## ------------------------------- Development Stage ------------------------------ ## + +FROM production AS development + +# Install dev requirements +RUN pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check -q -r requirements/dev-requirements.txt -# start period is high to allow data migration for 1.4.0 -HEALTHCHECK --interval=10s --timeout=2s --start-period=500s --retries=3 CMD nc -z localhost 8001 || exit 1 +## ------------------------------- Default Stage ------------------------------ ## +# Ensure the default build target is production +FROM production diff --git a/docker/Dockerfile_nginx b/docker/Dockerfile_nginx index dc8a3172..971b9a2a 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -1,11 +1,12 @@ FROM library/nginx:1.29.5-alpine -RUN mkdir -p /var/cache/nginx /var/cache/nginx/feeds -RUN apk update && apk upgrade && apk add bash + ENV NGINX_LOG_DIR=/var/log/nginx -# this is to avoid having these logs redirected to stdout/stderr -RUN rm $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log -RUN touch $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log -RUN chown 33:33 $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log -VOLUME $NGINX_LOG_DIR -HEALTHCHECK --interval=3s --start-period=2s --timeout=2s --retries=5 CMD curl --fail http://localhost/hc || exit 1 \ No newline at end of file +RUN apk add --no-cache bash + +# this is to avoid having logs redirected to stdout/stderr +RUN mkdir -p /var/cache/nginx /var/cache/nginx/feeds \ + && rm $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log \ + && touch $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log + +VOLUME $NGINX_LOG_DIR \ No newline at end of file diff --git a/docker/default.yml b/docker/default.yml index b4c50388..f8f2ee4a 100644 --- a/docker/default.yml +++ b/docker/default.yml @@ -1,7 +1,3 @@ -x-no-healthcheck: &no-healthcheck - healthcheck: - disable: true - services: postgres: image: library/postgres:18-alpine @@ -13,10 +9,11 @@ services: - ./env_file_postgres healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] - interval: 5s - timeout: 3s - retries: 5 - start_period: 5s + interval: 10s + timeout: 2s + retries: 3 + start_period: 10s + start_interval: 1s uwsgi: image: intelowlproject/greedybear:prod @@ -28,6 +25,7 @@ services: - static_content:/opt/deploy/greedybear/static entrypoint: - ./docker/entrypoint_uwsgi.sh + command: ["uwsgi", "--ini", "/etc/uwsgi/sites/greedybear.ini", "--stats", "127.0.0.1:1717", "--stats-http"] expose: - "8001" - "1717" @@ -37,11 +35,12 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "nc -z 127.0.0.1 8001 || exit 1"] + test: ["CMD-SHELL", "curl -f http://localhost:8001 || exit 1"] interval: 10s - timeout: 5s + timeout: 2s retries: 30 - start_period: 60s + start_period: 120s + start_interval: 1s nginx: image: intelowlproject/greedybear_nginx:prod @@ -55,10 +54,16 @@ services: - static_content:/var/www/static ports: - "80:80" - depends_on: - - uwsgi - - + depends_on: + uwsgi: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost/hc || exit 1"] + interval: 10s + timeout: 2s + retries: 3 + start_period: 10s + start_interval: 1s qcluster: image: intelowlproject/greedybear:prod @@ -77,7 +82,8 @@ services: uwsgi: condition: service_healthy user: "2000:82" - <<: *no-healthcheck + healthcheck: + disable: true volumes: postgres_data: diff --git a/docker/entrypoint_uwsgi.sh b/docker/entrypoint_uwsgi.sh index 96bf8343..8daec987 100755 --- a/docker/entrypoint_uwsgi.sh +++ b/docker/entrypoint_uwsgi.sh @@ -8,6 +8,8 @@ done # Apply database migrations # Create cache table for Django Q monitoring (idempotent) python manage.py createcachetable + +# Make durin migrations and migrate python manage.py makemigrations durin python manage.py migrate @@ -23,13 +25,8 @@ export VITE_GREEDYBEAR_VERSION echo "------------------------------" echo "GreedyBear $VITE_GREEDYBEAR_VERSION" -echo "DEBUG: " $DEBUG -echo "DJANGO_TEST_SERVER: " $DJANGO_TEST_SERVER +echo "DEBUG: $DEBUG" +echo "DJANGO_TEST_SERVER: $DJANGO_TEST_SERVER" echo "------------------------------" -if [[ $DEBUG == "True" ]] && [[ $DJANGO_TEST_SERVER == "True" ]]; -then - python manage.py runserver 0.0.0.0:8001 -else - /usr/local/bin/uwsgi --ini /etc/uwsgi/sites/greedybear.ini --stats 127.0.0.1:1717 --stats-http -fi +exec "$@" diff --git a/docker/local.override.yml b/docker/local.override.yml index db006883..d3b04bee 100644 --- a/docker/local.override.yml +++ b/docker/local.override.yml @@ -3,16 +3,14 @@ services: build: context: .. dockerfile: docker/Dockerfile - args: - WATCHMAN: "true" - INSTALL_DEV: "true" + target: development image: intelowlproject/greedybear:test volumes: - ../:/opt/deploy/greedybear + command: python manage.py runserver 0.0.0.0:8001 environment: - DEBUG=True - DJANGO_TEST_SERVER=True - - DJANGO_WATCHMAN_TIMEOUT=20 nginx: build: diff --git a/docker/watchman_install.sh b/docker/watchman_install.sh deleted file mode 100755 index 4958897c..00000000 --- a/docker/watchman_install.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -# This script can be disabled during development using REPO_DOWNLOADER_ENABLED=true env variable -if [ "$WATCHMAN" = "false" ]; then echo "Skipping WATCHMAN installation because we are not in test mode"; exit 0; fi - -apt-get update && apt-get install -y --no-install-recommends gcc build-essential unzip wget -pip3 install --compile -r requirements/django-server-requirements.txt - -# install Watchman to enhance performance on the Django development Server -# https://docs.djangoproject.com/en/5.2/ref/django-admin/#runserver -cd /tmp -wget https://github.com/facebook/watchman/releases/download/v2026.01.05.00/watchman-v2026.01.05.00-linux.zip -unzip watchman-*-linux.zip -cd watchman-*-linux/ -mkdir -p /usr/local/{bin,lib} /usr/local/var/run/watchman -cp bin/* /usr/local/bin -cp lib/* /usr/local/lib -chmod 755 /usr/local/bin/watchman -chmod 2777 /usr/local/var/run/watchman -rm -rf watchman-*-linux* \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 57550837..aff84cf2 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -35,6 +35,17 @@ export default defineConfig({ build: { outDir: 'build', - sourcemap: false + sourcemap: false, + rollupOptions: { + output: { + // Split large dependencies into separate chunks for better caching and smaller initial load + manualChunks: { + recharts: ['recharts'], + vendor: ['react', 'react-dom', 'react-router-dom'], + certego: ['@certego/certego-ui'], + reactstrap: ['reactstrap'], + }, + }, + }, } }); diff --git a/greedybear/settings.py b/greedybear/settings.py index 33e902bc..0fc670ed 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -91,6 +91,9 @@ "rest_email_auth", ] +if DEBUG: + INSTALLED_APPS.append("django_watchfiles") + # required by the certego-saas, but GreedyBear doesn't use the recaptcha, for this reason is filled with a placeholder DRF_RECAPTCHA_SECRET_KEY = "not-active" diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt index e0c6a9b0..8619cdf7 100644 --- a/requirements/dev-requirements.txt +++ b/requirements/dev-requirements.txt @@ -1,5 +1,4 @@ # Development requirements -# Installed conditionally in Docker: INSTALL_DEV=true -# For manual installation: pip install -r requirements/dev-requirements.txt coverage>=7.3.2 django-test-migrations>=1.5.0 +django-watchfiles>=1.4.0 diff --git a/requirements/django-server-requirements.txt b/requirements/django-server-requirements.txt deleted file mode 100644 index fa2160f5..00000000 --- a/requirements/django-server-requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -# to allow Watchman to be used with Django Development Server -pywatchman==2.0.0 \ No newline at end of file diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt index 77266269..9a8e720f 100644 --- a/requirements/project-requirements.txt +++ b/requirements/project-requirements.txt @@ -15,7 +15,7 @@ certego-saas==0.7.11 slack-sdk==3.40.1 uwsgitop==0.12 -uwsgi==2.0.31 +pyuwsgi==2.0.30 joblib==1.5.3 pandas==3.0.1 From bf91ee91432db3346efd52645f6eafd852f8962b Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:10:45 +0100 Subject: [PATCH 006/109] Fix Docker regressions from PR #890. Closes #898 (#900) * add libexpat1 as a runtime requirement again * use different health checks for uWSGI and Django dev server * only add django_watchfiles to installed apps when Django test server is running * make uWSGI stop gracefully on SIGTERM --- configuration/uwsgi/greedybear.ini | 1 + docker/Dockerfile | 3 ++- docker/default.yml | 2 +- docker/local.override.yml | 2 ++ greedybear/settings.py | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/configuration/uwsgi/greedybear.ini b/configuration/uwsgi/greedybear.ini index 0fd823ef..1b02118e 100644 --- a/configuration/uwsgi/greedybear.ini +++ b/configuration/uwsgi/greedybear.ini @@ -12,6 +12,7 @@ socket = 0.0.0.0:8001 chown = www-data:www-data vacuum = true single-interpreter = true +die-on-term = true logto = /var/log/greedybear/uwsgi/greedybear.log uid = www-data diff --git a/docker/Dockerfile b/docker/Dockerfile index a8ac894c..18a33378 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,9 +27,10 @@ WORKDIR $PYTHONPATH # Install runtime dependencies # - libgomp1 is required for model training +# - libexpat1 is required by uWSGI # - curl for healthcheck RUN apt-get update && apt-get install -y --no-install-recommends \ - libgomp1 curl \ + libgomp1 libexpat1 curl \ && rm -rf /var/lib/apt/lists/* # Install python packages diff --git a/docker/default.yml b/docker/default.yml index f8f2ee4a..42f28a8b 100644 --- a/docker/default.yml +++ b/docker/default.yml @@ -35,7 +35,7 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:8001 || exit 1"] + test: ["CMD-SHELL", "curl -f http://localhost:1717 || exit 1"] interval: 10s timeout: 2s retries: 30 diff --git a/docker/local.override.yml b/docker/local.override.yml index d3b04bee..37a93c19 100644 --- a/docker/local.override.yml +++ b/docker/local.override.yml @@ -8,6 +8,8 @@ services: volumes: - ../:/opt/deploy/greedybear command: python manage.py runserver 0.0.0.0:8001 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8001 || exit 1"] environment: - DEBUG=True - DJANGO_TEST_SERVER=True diff --git a/greedybear/settings.py b/greedybear/settings.py index 0fc670ed..fdef5a44 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -91,7 +91,7 @@ "rest_email_auth", ] -if DEBUG: +if os.environ.get("DJANGO_TEST_SERVER", "False") == "True": INSTALLED_APPS.append("django_watchfiles") # required by the certego-saas, but GreedyBear doesn't use the recaptcha, for this reason is filled with a placeholder From d68c85cacafaf631aae2d3b5038ecd73efa76b20 Mon Sep 17 00:00:00 2001 From: Tanmay Joddar <152395649+tanmayjoddar@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:44:40 +0530 Subject: [PATCH 007/109] Replace regex IP validation with ipaddress stdlib in EnrichmentSerializer. Closes #881 (#885) - Create greedybear/utils.py with shared validation helpers (is_ip_address, is_sha256hash) - Replace REGEX_IP with is_ip_address() for proper IPv4/IPv6 validation - Add .strip() with write-back for whitespace handling - Improve domain validation to require at least one alphabetic character - Update all import sites to use greedybear.utils - Remove moved functions and unused imports from api/views/utils.py - Add regression tests for invalid IPs, valid IPv6, whitespace, and domains --- api/serializers.py | 19 ++++++---- api/views/command_sequence.py | 2 +- api/views/cowrie_session.py | 2 +- api/views/utils.py | 39 -------------------- greedybear/utils.py | 41 ++++++++++++++++++++++ tests/api/views/test_enrichment_view.py | 27 ++++++++++++++ tests/api/views/test_validation_helpers.py | 2 +- 7 files changed, 83 insertions(+), 49 deletions(-) create mode 100644 greedybear/utils.py diff --git a/api/serializers.py b/api/serializers.py index ba2b0ce9..dfb228a0 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -5,8 +5,9 @@ from django.core.exceptions import FieldDoesNotExist from rest_framework import serializers -from greedybear.consts import REGEX_DOMAIN, REGEX_IP +from greedybear.consts import REGEX_DOMAIN from greedybear.models import IOC, GeneralHoneypot +from greedybear.utils import is_ip_address logger = logging.getLogger(__name__) @@ -36,13 +37,17 @@ class EnrichmentSerializer(serializers.Serializer): def validate(self, data): """ - Check a given observable against regex expression + Validate that the query is a valid IP address (IPv4/IPv6) or domain. """ - observable = data["query"] - if re.match(r"^[\d\.]+$", observable) and not re.match(REGEX_IP, observable): - raise serializers.ValidationError("Observable is not a valid IP") - if not re.match(REGEX_IP, observable) and not re.match(REGEX_DOMAIN, observable): - raise serializers.ValidationError("Observable is not a valid IP or domain") + observable = data["query"].strip() + data["query"] = observable + + # A valid domain must match the domain regex AND contain at least one alphabetic character + is_domain = bool(re.match(REGEX_DOMAIN, observable)) and any(c.isalpha() for c in observable) + + if not is_ip_address(observable) and not is_domain: + raise serializers.ValidationError("Observable is not a valid IP address or domain") + try: required_object = IOC.objects.get(name=observable) data["found"] = True diff --git a/api/views/command_sequence.py b/api/views/command_sequence.py index a5137241..a1d0b0c1 100644 --- a/api/views/command_sequence.py +++ b/api/views/command_sequence.py @@ -14,9 +14,9 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from api.views.utils import is_ip_address, is_sha256hash from greedybear.consts import GET from greedybear.models import IOC, CommandSequence, CowrieSession, Statistics, ViewType +from greedybear.utils import is_ip_address, is_sha256hash logger = logging.getLogger(__name__) diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index ed7c9bf8..aea2edc0 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -16,9 +16,9 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from api.views.utils import is_ip_address, is_sha256hash from greedybear.consts import GET from greedybear.models import CommandSequence, CowrieSession, Statistics, ViewType +from greedybear.utils import is_ip_address, is_sha256hash logger = logging.getLogger(__name__) diff --git a/api/views/utils.py b/api/views/utils.py index f7c2b180..e8bcdcf7 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -2,9 +2,7 @@ # See the file 'LICENSE' for copying permission. import csv import logging -import re from datetime import datetime, timedelta -from ipaddress import ip_address import feedparser import requests @@ -309,43 +307,6 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose return HttpResponseBadRequest() -def is_ip_address(string: str) -> bool: - """ - Validate if a string is a valid IP address (IPv4 or IPv6). - - Uses the ipaddress module to perform validation. This function properly - handles both IPv4 addresses and IPv6 addresses. - - Args: - string: The string to validate as an IP address - - Returns: - bool: True if the string is a valid IP address, False otherwise - """ - try: - ip_address(string) - except ValueError: - return False - return True - - -def is_sha256hash(string: str) -> bool: - """ - Validate if a string is a valid SHA-256 hash. - - A SHA-256 hash is a string of exactly 64 hexadecimal characters - (0-9, a-f, A-F). This function checks if the input string matches - this pattern using a regular expression. - - Args: - string: The string to validate as a SHA-256 hash - - Returns: - bool: True if the string is a valid SHA-256 hash, False otherwise - """ - return bool(re.fullmatch(r"^[A-Fa-f0-9]{64}$", string)) - - def asn_aggregated_queryset(iocs_qs, request, feed_params): """ Perform DB-level aggregation grouped by ASN. diff --git a/greedybear/utils.py b/greedybear/utils.py new file mode 100644 index 00000000..f79a01aa --- /dev/null +++ b/greedybear/utils.py @@ -0,0 +1,41 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +import re +from ipaddress import ip_address + + +def is_ip_address(string: str) -> bool: + """ + Validate if a string is a valid IP address (IPv4 or IPv6). + + Uses the ipaddress module to perform validation. This function properly + handles both IPv4 addresses and IPv6 addresses. + + Args: + string: The string to validate as an IP address + + Returns: + bool: True if the string is a valid IP address, False otherwise + """ + try: + ip_address(string) + except ValueError: + return False + return True + + +def is_sha256hash(string: str) -> bool: + """ + Validate if a string is a valid SHA-256 hash. + + A SHA-256 hash is a string of exactly 64 hexadecimal characters + (0-9, a-f, A-F). This function checks if the input string matches + this pattern using a regular expression. + + Args: + string: The string to validate as a SHA-256 hash + + Returns: + bool: True if the string is a valid SHA-256 hash, False otherwise + """ + return bool(re.fullmatch(r"^[A-Fa-f0-9]{64}$", string)) diff --git a/tests/api/views/test_enrichment_view.py b/tests/api/views/test_enrichment_view.py index 47653f32..e7ad06f4 100644 --- a/tests/api/views/test_enrichment_view.py +++ b/tests/api/views/test_enrichment_view.py @@ -59,3 +59,30 @@ def test_for_invalid_authentication(self): self.client.logout() response = self.client.get("/api/enrichment?query=140.246.171.141") self.assertEqual(response.status_code, 401) + + def test_invalid_ip_octet_over_255(self): + """IPs with octets > 255 should be rejected (fixes #881)""" + response = self.client.get("/api/enrichment?query=999.999.999.999") + self.assertEqual(response.status_code, 400) + + def test_invalid_ip_octet_256(self): + """IP with octet = 256 should be rejected""" + response = self.client.get("/api/enrichment?query=256.1.1.1") + self.assertEqual(response.status_code, 400) + + def test_valid_ipv6_address(self): + """Valid IPv6 addresses should be accepted""" + response = self.client.get("/api/enrichment?query=2001:db8::1") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["found"], False) + + def test_query_with_whitespace(self): + """Query with leading/trailing whitespace should be handled""" + response = self.client.get("/api/enrichment?query= 192.168.0.1 ") + self.assertEqual(response.status_code, 200) + + def test_valid_domain(self): + """Valid domain names should be accepted""" + response = self.client.get("/api/enrichment?query=example.com") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["found"], False) diff --git a/tests/api/views/test_validation_helpers.py b/tests/api/views/test_validation_helpers.py index 2e9f900e..e83e2e15 100644 --- a/tests/api/views/test_validation_helpers.py +++ b/tests/api/views/test_validation_helpers.py @@ -1,4 +1,4 @@ -from api.views.utils import is_ip_address, is_sha256hash +from greedybear.utils import is_ip_address, is_sha256hash from tests import CustomTestCase From 8b69a1b20c3a7b5b9f67085df2860cf03da9d445 Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Sat, 28 Feb 2026 02:02:56 +0545 Subject: [PATCH 008/109] feat(pipeline): GeoIP enrichment . Closes #524 (#880) * version(1): GeoIpEnrichment Signed-off-by: Drona Raj Gyawali * version(2): refactor pipeline & code logic Signed-off-by: Dorna Raj Gyawali * chores: linter resolved Signed-off-by: Dorna Raj Gyawali * version(3) : refactor code & logic --------- Signed-off-by: Drona Raj Gyawali Signed-off-by: Dorna Raj Gyawali --- .../cronjobs/extraction/ioc_processor.py | 4 ++ greedybear/cronjobs/extraction/pipeline.py | 8 ++- greedybear/cronjobs/extraction/utils.py | 4 ++ greedybear/cronjobs/repositories/sensor.py | 18 ++++++ ...ttacker_country_sensor_country_and_more.py | 27 +++++++++ greedybear/models.py | 11 ++++ .../cronjobs/test_extraction_pipeline_e2e.py | 59 +++++++++++++++++++ tests/test_extraction_utils.py | 25 ++++++++ tests/test_sensor_repository.py | 36 +++++++++++ 9 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 greedybear/migrations/0039_ioc_attacker_country_sensor_country_and_more.py diff --git a/greedybear/cronjobs/extraction/ioc_processor.py b/greedybear/cronjobs/extraction/ioc_processor.py index 864965ca..a00e4159 100644 --- a/greedybear/cronjobs/extraction/ioc_processor.py +++ b/greedybear/cronjobs/extraction/ioc_processor.py @@ -100,6 +100,10 @@ def _merge_iocs(self, existing: IOC, new: IOC) -> IOC: existing.asn = new.asn existing.login_attempts += new.login_attempts + # we will always update attacker_country if incoming value exists + if new.attacker_country: + existing.attacker_country = new.attacker_country + # Add sensors from new IOC (existing is already saved, so ManyToMany works). # We retrieve sensors from the temporary attribute of the input IOC object. if hasattr(new, "_sensors_to_add") and new._sensors_to_add: diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py index 969df89e..8f50e4f3 100644 --- a/greedybear/cronjobs/extraction/pipeline.py +++ b/greedybear/cronjobs/extraction/pipeline.py @@ -74,9 +74,15 @@ def execute(self) -> int: 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 object for strategies + hit_dict["_sensor"] = sensor # include sensor for strategies + + sensor_country = hit_dict.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) # 3. Extract using strategies diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index fa0d8492..6f57aa79 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -119,6 +119,9 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: # Sort sensors by ID for consistent processing order sensors.sort(key=lambda s: s.id) + geoip = hits[0].get("geoip", {}) if hits else {} + attacker_country = geoip.get("country_name", "") + ioc = IOC( name=ip, type=get_ioc_type(ip), @@ -128,6 +131,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: destination_ports=sorted(set(dest_ports)), login_attempts=len(hits) if hits[0].get("type", "") == "Heralding" else 0, firehol_categories=firehol_categories, + attacker_country=attacker_country, ) # Attach sensors to temporary attribute for later processing. # We cannot use `ioc.sensors.add()` here because the IOC instance is not yet saved diff --git a/greedybear/cronjobs/repositories/sensor.py b/greedybear/cronjobs/repositories/sensor.py index 12a35bbd..efc51b76 100644 --- a/greedybear/cronjobs/repositories/sensor.py +++ b/greedybear/cronjobs/repositories/sensor.py @@ -46,3 +46,21 @@ def _fill_cache(self) -> None: """Load sensor objects from the database into the cache.""" self.log.debug("populating sensor cache") self.cache = {s.address: s for s in Sensor.objects.all()} + + def update_country(self, sensor: Sensor, country: str) -> None: + """ + Update the country of a sensor if it has changed. + + Args: + sensor: The Sensor instance to update. + country: The new country value. + """ + if not sensor or not country: + return + + if sensor.country == country: + return + + self.log.debug(f"Updating country for sensor {sensor.address} to {country}") + sensor.country = country + sensor.save(update_fields=["country"]) diff --git a/greedybear/migrations/0039_ioc_attacker_country_sensor_country_and_more.py b/greedybear/migrations/0039_ioc_attacker_country_sensor_country_and_more.py new file mode 100644 index 00000000..910b0dcb --- /dev/null +++ b/greedybear/migrations/0039_ioc_attacker_country_sensor_country_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.11 on 2026-02-25 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0038_add_tag_model'), + ] + + operations = [ + migrations.AddField( + model_name='ioc', + name='attacker_country', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='sensor', + name='country', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddIndex( + model_name='ioc', + index=models.Index(fields=['attacker_country'], name='greedybear__attacke_2b9c7d_idx'), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 4bbae7d8..738a5cf1 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -19,6 +19,11 @@ class IocType(models.TextChoices): class Sensor(models.Model): address = models.GenericIPAddressField(unique=True) + country = models.CharField( + max_length=64, + blank=True, + default="", + ) def __str__(self): return self.address @@ -58,6 +63,11 @@ class IOC(models.Model): number_of_days_seen = models.IntegerField(default=1) attack_count = models.IntegerField(default=1) interaction_count = models.IntegerField(default=1) + attacker_country = models.CharField( + max_length=64, + blank=True, + default="", + ) # FEEDS - list of honeypots from general list, from which the IOC was detected general_honeypot = models.ManyToManyField(GeneralHoneypot, blank=True) # SENSORS - list of T-Pot sensors that detected this IOC @@ -78,6 +88,7 @@ class IOC(models.Model): class Meta: indexes = [ models.Index(fields=["name"]), + models.Index(fields=["attacker_country"]), ] def __str__(self): diff --git a/tests/greedybear/cronjobs/test_extraction_pipeline_e2e.py b/tests/greedybear/cronjobs/test_extraction_pipeline_e2e.py index be7a66c3..6dd7688d 100644 --- a/tests/greedybear/cronjobs/test_extraction_pipeline_e2e.py +++ b/tests/greedybear/cronjobs/test_extraction_pipeline_e2e.py @@ -10,6 +10,8 @@ from unittest.mock import MagicMock, patch +from greedybear.cronjobs.extraction.ioc_processor import IocProcessor +from greedybear.models import Sensor from tests import E2ETestCase, MockElasticHit @@ -428,3 +430,60 @@ def test_ioc_scanner_field_contains_honeypot_type(self, mock_scores): "IOC scanner field should contain 'Heralding'", ) break + + +class TestGeoIPEnrichmentE2E(E2ETestCase): + """E2E test for sensor GeoIP enrichment.""" + + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + def test_geoip_enrichment_with_ioc(self, mock_scores): + pipeline = self._create_pipeline_with_real_factory() + + # Real sensor + real_sensor = Sensor(address="10.10.10.10", country="") + pipeline.sensor_repo.get_or_create_sensor.return_value = real_sensor + + # Patch update_country to actually set the country + pipeline.sensor_repo.update_country.side_effect = lambda sensor, country: setattr(sensor, "country", country) + + hits = [ + MockElasticHit( + { + "src_ip": "8.8.8.8", + "type": "Heralding", + "dest_port": 22, + "@timestamp": "2025-01-01T10:00:00", + "geoip_ext": {"country_name": "Nepal"}, + "t-pot_ip_ext": "10.10.10.10", + "geoip": {"country_name": "Nepal", "asn": 64512}, + } + ), + ] + + 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 + pipeline.ioc_repo.get_ioc_by_name.return_value = None + + # Patch add_ioc to just return the IOC generated from hits + real_iocs = [] + + def add_ioc_side_effect(self, ioc, *args, **kwargs): + real_iocs.append(ioc) + return ioc # return the real IOC object + + with patch.object(IocProcessor, "add_ioc", new=add_ioc_side_effect): + result = pipeline.execute() + + # Verify sensor was enriched + self.assertEqual(real_sensor.country, "Nepal") + + # Verify IOC attacker_country is populated + self.assertTrue(real_iocs) + self.assertEqual(real_iocs[0].attacker_country, "Nepal") + + # Result should reflect IOC count + self.assertGreater(result, 0) + + # UpdateScores called with the real IOC + mock_scores.return_value.score_only.assert_called_once_with(real_iocs) diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index abdeb40e..97b9e2bc 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -597,6 +597,31 @@ def test_returns_empty_sensors_when_no_sensor_in_hits(self): sensor = getattr(ioc, "_sensors_to_add", []) self.assertEqual(sensor, []) + def test_ioc_attacker_country_set_correctly(self): + """Verify that iocs_from_hits sets src_ip and attacker_country correctly.""" + hits = [ + self._create_hit( + src_ip="8.8.8.8", + dest_port=22, + hit_type="Cowrie", + asn=12345, + ) + ] + + # manually injecting the geo + hits[0]["geoip"] = {"country_name": "Nepal"} + + iocs = iocs_from_hits(hits) + self.assertEqual(len(iocs), 1) + + ioc = iocs[0] + + # verifying the ip and country + self.assertEqual(ioc.name, "8.8.8.8") + self.assertEqual(ioc.attacker_country, "Nepal") + + self.assertEqual(ioc.interaction_count, 1) + class ThreatfoxSubmissionTestCase(ExtractionTestCase): def setUp(self): diff --git a/tests/test_sensor_repository.py b/tests/test_sensor_repository.py index 21985555..ad46666f 100644 --- a/tests/test_sensor_repository.py +++ b/tests/test_sensor_repository.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from greedybear.cronjobs.repositories import SensorRepository from greedybear.models import Sensor @@ -56,3 +58,37 @@ def test_get_or_create_sensor_accepts_valid_ipv4(self): result = self.repo.get_or_create_sensor(ip) self.assertIsNotNone(result) self.assertIsInstance(result, Sensor) + + def test_update_country_sets_country(self): + """update_country sets the Sensor's country if different.""" + sensor = Sensor.objects.create(address="1.2.3.4", country="") + + self.repo.update_country(sensor, "Nepal") + + sensor.refresh_from_db() + self.assertEqual(sensor.country, "Nepal") + + def test_update_country_skips_if_same_value(self): + """update_country does not call save if country is unchanged.""" + sensor = Sensor.objects.create(address="1.2.3.5", country="Nepal") + + with patch.object(Sensor, "save") as mock_save: + self.repo.update_country(sensor, "Nepal") + mock_save.assert_not_called() + + def test_update_country_updates_if_different(self): + """update_country writes to DB if country differs.""" + sensor = Sensor.objects.create(address="1.2.3.6", country="India") + + with patch.object(Sensor, "save") as mock_save: + self.repo.update_country(sensor, "Nepal") + mock_save.assert_called_once() + + def test_update_country_skips_if_invalid_input(self): + """update_country should not save if sensor is None or country is empty.""" + sensor = Sensor.objects.create(address="1.2.3.7", country="") + + with patch.object(Sensor, "save") as mock_save: + self.repo.update_country(None, "Nepal") + self.repo.update_country(sensor, "") + mock_save.assert_not_called() From cad5d454bc942bb14a346661022fb7edd919aa08 Mon Sep 17 00:00:00 2001 From: Deepanshu <144600350+Deepanshu1230@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:57:17 +0530 Subject: [PATCH 009/109] Fix: clear user data and isSuperuser on logout. Closes #893 (#897) * Fix: clear user data and isSuperuser on logout * Fix: use AUTHENTICATION_STATUSES.FALSE instead of toBeFalsy in test * Fix: correct test for user data clearing on logout * Fixing the Formatter and Linting --- frontend/src/stores/useAuthStore.jsx | 6 +++- .../tests/components/auth/Logout.test.jsx | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/stores/useAuthStore.jsx b/frontend/src/stores/useAuthStore.jsx index 3d3b8eb7..d3b434ef 100644 --- a/frontend/src/stores/useAuthStore.jsx +++ b/frontend/src/stores/useAuthStore.jsx @@ -73,7 +73,11 @@ const useAuthStore = create((set, get) => ({ logoutUser: async () => { set({ isAuthenticated: AUTHENTICATION_STATUSES.PENDING }); const onLogoutCb = () => { - set({ isAuthenticated: AUTHENTICATION_STATUSES.FALSE }); + set({ + isAuthenticated: AUTHENTICATION_STATUSES.FALSE, + user: { full_name: "", first_name: "", last_name: "", email: "" }, + isSuperuser: false, + }); addToast("Logged out!", null, "info"); }; return axios diff --git a/frontend/tests/components/auth/Logout.test.jsx b/frontend/tests/components/auth/Logout.test.jsx index 37d4cdc1..2a38dc62 100644 --- a/frontend/tests/components/auth/Logout.test.jsx +++ b/frontend/tests/components/auth/Logout.test.jsx @@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom"; import { LOGOUT_URI } from "../../../src/constants/api"; import Logout from "../../../src/components/auth/Logout"; import { useAuthStore } from "../../../src/stores"; +import { AUTHENTICATION_STATUSES } from "../../../src/constants"; vi.mock("axios"); @@ -30,4 +31,31 @@ describe("Logout component", () => { }); expect(await screen.findByText("Logging you out...")).toBeInTheDocument(); }); + + test("User data and isSuperuser are cleared after logout", async () => { + // set store with correct user shape matching useAuthStore initial values + useAuthStore.setState({ + isAuthenticated: AUTHENTICATION_STATUSES.TRUE, + user: { + full_name: "Test User", + first_name: "Test", + last_name: "User", + email: "test@test.com", + }, + isSuperuser: true, + }); + + await useAuthStore.getState().service.logoutUser(); + + expect(useAuthStore.getState().user).toEqual({ + full_name: "", + first_name: "", + last_name: "", + email: "", + }); + expect(useAuthStore.getState().isSuperuser).toBe(false); + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.FALSE, + ); + }); }); From ef8421c55c00bc9fd6e09c8f0adbf051a59378a7 Mon Sep 17 00:00:00 2001 From: Usama Mohammed Elareeny Date: Mon, 2 Mar 2026 09:19:33 +0200 Subject: [PATCH 010/109] Feeds filters controlled by Formik state. Closes #889 (#894) * Fix: Feeds Select controlled by Formik values * test: add coverage for Formik-controlled Feeds filters * refactor: made Formik the single source of truth instead of filters/Formik * review: remove unnecessary explanatory comment --- frontend/src/components/feeds/Feeds.jsx | 293 ++++++++++-------- .../tests/components/feeds/Feeds.test.jsx | 35 +++ 2 files changed, 200 insertions(+), 128 deletions(-) diff --git a/frontend/src/components/feeds/Feeds.jsx b/frontend/src/components/feeds/Feeds.jsx index 681fea79..210fd0de 100644 --- a/frontend/src/components/feeds/Feeds.jsx +++ b/frontend/src/components/feeds/Feeds.jsx @@ -2,7 +2,7 @@ import React from "react"; import { Container, Button, Col, Label, FormGroup, Row } from "reactstrap"; import { VscJson } from "react-icons/vsc"; import { TbLicense } from "react-icons/tb"; -import { useNavigate, useLocation } from "react-router-dom"; +import { useLocation } from "react-router-dom"; import { FEEDS_BASE_URI, GENERAL_HONEYPOT_URI } from "../../constants/api"; import { ContentSection, @@ -36,12 +36,12 @@ const prioritizationChoices = [ { label: "Most expected hits", value: "most_expected_hits" }, ]; -const initialValues = { +const DEFAULT_VALUES = Object.freeze({ feeds_type: "all", attack_type: "all", ioc_type: "all", prioritize: "recent", -}; +}); const toPassTableProps = { columns: feedsTableColumns, @@ -100,17 +100,19 @@ function FeedsTable({ tableParams, onDataLoad, onSortChange }) { export default function Feeds() { console.debug("Feeds rendered!"); + console.debug("Feeds-DEFAULT_VALUES", DEFAULT_VALUES); + const formikRef = React.useRef(null); - console.debug("Feeds-initialValues", initialValues); - - const navigate = useNavigate(); - - const [url, setUrl] = React.useState( - `${FEEDS_BASE_URI}/${initialValues.feeds_type}/${initialValues.attack_type}/${initialValues.prioritize}.json`, - ); - - // Counter used to force remount FeedsTable - const [tableKey, setTableKey] = React.useState(0); + const [feedsState, setFeedsState] = React.useState({ + url: `${FEEDS_BASE_URI}/${DEFAULT_VALUES.feeds_type}/${DEFAULT_VALUES.attack_type}/${DEFAULT_VALUES.prioritize}.json`, + tableParams: { + feed_type: DEFAULT_VALUES.feeds_type, + attack_type: DEFAULT_VALUES.attack_type, + ioc_type: DEFAULT_VALUES.ioc_type, + prioritize: DEFAULT_VALUES.prioritize, + }, + tableKey: 0, + }); // feedsData is lifted from FeedsTable so we can show the count in the header const [feedsData, setFeedsData] = React.useState(null); @@ -133,37 +135,33 @@ export default function Feeds() { }); // reset the prioritize dropdown to "recent" - const handleSortChange = React.useCallback(() => { - initialValues.prioritize = "recent"; - setUrl( - `${FEEDS_BASE_URI}/${initialValues.feeds_type}/${initialValues.attack_type}/recent.json?ioc_type=${initialValues.ioc_type}`, - ); - setTableKey((prev) => prev + 1); - }, [setUrl]); + const handleSortChange = React.useCallback(async () => { + const formik = formikRef.current; + if (!formik) return; - // callbacks - const onSubmit = React.useCallback( - (values) => { - try { - setUrl( - `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, - ); - initialValues.feeds_type = values.feeds_type; - initialValues.attack_type = values.attack_type; - initialValues.ioc_type = values.ioc_type; - initialValues.prioritize = values.prioritize; + await formik.setFieldValue("prioritize", "recent", true); + await formik.setFieldTouched("prioritize", true, false); + await formik.submitForm(); + }, []); - // Clear any ordering / page query params. - navigate({ search: "" }, { replace: true }); + // callbacks + const onSubmit = React.useCallback((values) => { + try { + setFeedsState((prev) => ({ + url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, + tableParams: { + feed_type: values.feeds_type, + attack_type: values.attack_type, + ioc_type: values.ioc_type, + prioritize: values.prioritize, + }, - // force remount FeedsTable - setTableKey((prev) => prev + 1); - } catch (e) { - console.debug(e); - } - }, - [setUrl, navigate], - ); + tableKey: prev.tableKey + 1, + })); + } catch (e) { + console.debug(e); + } + }, []); return ( @@ -189,85 +187,129 @@ export default function Feeds() { {/* Form */} ( - - {(formik) => ( -
- - - - { - formik.handleChange(e); - formik.submitForm(); - }} - /> - - - - { - formik.handleChange(e); - formik.submitForm(); - }} - /> - - -
- )} + + {(formik) => { + return ( +
+ + + + { + await formik.setFieldValue( + "attack_type", + e.target.value, + true, + ); + await formik.setFieldTouched( + "attack_type", + true, + false, + ); + await formik.submitForm(); + }} + /> + + + + { + await formik.setFieldValue( + "prioritize", + e.target.value, + true, + ); + await formik.setFieldTouched( + "prioritize", + true, + false, + ); + await formik.submitForm(); + }} + /> + + +
+ ); + }}
)} /> @@ -281,7 +323,7 @@ export default function Feeds() { className="mb-3" color="primary" outline - href={url} + href={feedsState.url} target="_blank" > @@ -291,13 +333,8 @@ export default function Feeds() { {/*Table*/} diff --git a/frontend/tests/components/feeds/Feeds.test.jsx b/frontend/tests/components/feeds/Feeds.test.jsx index 0bd38ff7..68e62b44 100644 --- a/frontend/tests/components/feeds/Feeds.test.jsx +++ b/frontend/tests/components/feeds/Feeds.test.jsx @@ -67,6 +67,10 @@ vi.mock("@certego/certego-ui", async (importOriginal) => { }); describe("Feeds component", () => { + beforeEach(() => { + window.history.replaceState({}, "", "/"); + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -108,6 +112,11 @@ describe("Feeds component", () => { await user.selectOptions(iocTypeSelectElement, "ip"); await user.selectOptions(prioritizationSelectElement, "persistent"); + expect(feedTypeSelectElement).toHaveValue("cowrie"); + expect(attackTypeSelectElement).toHaveValue("scanner"); + expect(iocTypeSelectElement).toHaveValue("ip"); + expect(prioritizationSelectElement).toHaveValue("persistent"); + await waitFor(() => { // check link has been changed including ioc_type parameter expect(buttonRawData).toHaveAttribute( @@ -124,5 +133,31 @@ describe("Feeds component", () => { "/api/feeds/cowrie/scanner/persistent.json?ioc_type=domain", ); }); + + expect(iocTypeSelectElement).toHaveValue("domain"); + }); + + test("resets prioritize to recent when ordering is present and prioritize overrides ordering", async () => { + window.history.replaceState({}, "", "/?ordering=asc"); + const user = userEvent.setup(); + + render( + + + , + ); + + const prioritizationSelectElement = screen.getByLabelText("Prioritize:"); + const buttonRawData = screen.getByRole("link", { name: /Raw data/i }); + + await user.selectOptions(prioritizationSelectElement, "likely_to_recur"); + + await waitFor(() => { + expect(prioritizationSelectElement).toHaveValue("recent"); + expect(buttonRawData).toHaveAttribute( + "href", + "/api/feeds/all/all/recent.json?ioc_type=all", + ); + }); }); }); From 3a94276f19083910ed2bf7eb819e6e0054640490 Mon Sep 17 00:00:00 2001 From: Arnav Vinod Deshpande Date: Mon, 2 Mar 2026 14:01:11 +0530 Subject: [PATCH 011/109] Login attempts from various honeypots included. Fixes #470 (#911) * fix * COPILOT CHANGES --------- Co-authored-by: rootp1 --- .../cronjobs/extraction/strategies/cowrie.py | 1 - greedybear/cronjobs/extraction/utils.py | 25 +++++--- tests/test_cowrie_extraction.py | 1 - tests/test_extraction_utils.py | 59 +++++++++++++++++-- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/greedybear/cronjobs/extraction/strategies/cowrie.py b/greedybear/cronjobs/extraction/strategies/cowrie.py index 6eb9e9ab..58086aa3 100644 --- a/greedybear/cronjobs/extraction/strategies/cowrie.py +++ b/greedybear/cronjobs/extraction/strategies/cowrie.py @@ -245,7 +245,6 @@ def _process_session_hit(self, session_record: CowrieSession, hit: dict, ioc: IO username = normalize_credential_field(hit["username"]) password = normalize_credential_field(hit["password"]) session_record.credentials.append(f"{username} | {password}") - session_record.source.login_attempts += 1 case "cowrie.command.input": self.log.info(f"found a command execution from {ioc.name}") diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index 6f57aa79..2404db64 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -106,18 +106,30 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: hits_by_ip[hit["src_ip"]].append(hit) iocs = [] for ip, hits in hits_by_ip.items(): - dest_ports = [hit["dest_port"] for hit in hits if "dest_port" in hit] 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: continue firehol_categories = get_firehol_categories(ip, extracted_ip) - # Collect unique sensors from hits, deduplicated by sensor ID - sensors_map = {hit["_sensor"].id: hit["_sensor"] for hit in hits if hit.get("_sensor") is not None and getattr(hit["_sensor"], "id", None)} - sensors = list(sensors_map.values()) + # Single pass over hits to accumulate all derived data + dest_ports = [] + sensors_map = {} + timestamps = [] + login_attempts = 0 + for hit in hits: + if "dest_port" in hit: + dest_ports.append(hit["dest_port"]) + sensor = hit.get("_sensor") + if sensor is not None and getattr(sensor, "id", None): + sensors_map[sensor.id] = sensor + if "@timestamp" in hit: + timestamps.append(hit["@timestamp"]) + if hit.get("username") or hit.get("password"): + login_attempts += 1 + # Sort sensors by ID for consistent processing order - sensors.sort(key=lambda s: s.id) + sensors = sorted(sensors_map.values(), key=lambda s: s.id) geoip = hits[0].get("geoip", {}) if hits else {} attacker_country = geoip.get("country_name", "") @@ -129,7 +141,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: ip_reputation=correct_ip_reputation(ip, hits[0].get("ip_rep", "")), asn=hits[0].get("geoip", {}).get("asn"), destination_ports=sorted(set(dest_ports)), - login_attempts=len(hits) if hits[0].get("type", "") == "Heralding" else 0, + login_attempts=login_attempts, firehol_categories=firehol_categories, attacker_country=attacker_country, ) @@ -138,7 +150,6 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: # to the database, and Django requires an ID for M2M relationships. ioc._sensors_to_add = sensors - timestamps = [hit["@timestamp"] for hit in hits if "@timestamp" in hit] if timestamps: ioc.first_seen = datetime.fromisoformat(min(timestamps)) ioc.last_seen = datetime.fromisoformat(max(timestamps)) diff --git a/tests/test_cowrie_extraction.py b/tests/test_cowrie_extraction.py index 2f78633e..426dc985 100644 --- a/tests/test_cowrie_extraction.py +++ b/tests/test_cowrie_extraction.py @@ -239,7 +239,6 @@ def test_process_session_hit_login_failed(self): self.assertTrue(session_record.login_attempt) self.assertIn("root | password123", session_record.credentials) - self.assertEqual(session_record.source.login_attempts, 1) def test_process_session_hit_command_input(self): """Test processing of command input event.""" diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index 97b9e2bc..384491bd 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -326,6 +326,8 @@ def _create_hit( ip_rep="", asn=None, sensor=None, + username=None, + password=None, ): hit = { "src_ip": src_ip, @@ -338,6 +340,10 @@ def _create_hit( hit["geoip"] = {"asn": asn} if sensor: hit["_sensor"] = sensor + if username is not None: + hit["username"] = username + if password is not None: + hit["password"] = password return hit def test_creates_ioc_from_single_hit(self): @@ -473,17 +479,38 @@ def test_filters_reserved_addresses(self): ioc = iocs[0] self.assertEqual(ioc.name, "8.8.8.8") - def test_heralding_counts_login_attempts(self): + def test_counts_login_attempts_from_credentials(self): + """Hits with username or password fields are counted as login attempts.""" hits = [ - self._create_hit(src_ip="8.8.8.8", hit_type="Heralding"), - self._create_hit(src_ip="8.8.8.8", hit_type="Heralding"), - self._create_hit(src_ip="8.8.8.8", hit_type="Heralding"), + self._create_hit(src_ip="8.8.8.8", username="root", password="1234"), + self._create_hit(src_ip="8.8.8.8", username="admin", password="admin"), + self._create_hit(src_ip="8.8.8.8", username="test", password="test"), ] iocs = iocs_from_hits(hits) ioc = iocs[0] self.assertEqual(ioc.login_attempts, 3) - def test_non_heralding_no_login_attempts(self): + def test_counts_login_attempts_username_only(self): + """Hits with only username field are counted as login attempts.""" + hits = [ + self._create_hit(src_ip="8.8.8.8", username="root"), + self._create_hit(src_ip="8.8.8.8", username="admin"), + ] + iocs = iocs_from_hits(hits) + ioc = iocs[0] + self.assertEqual(ioc.login_attempts, 2) + + def test_counts_login_attempts_password_only(self): + """Hits with only password field are counted as login attempts.""" + hits = [ + self._create_hit(src_ip="8.8.8.8", password="1234"), + ] + iocs = iocs_from_hits(hits) + ioc = iocs[0] + self.assertEqual(ioc.login_attempts, 1) + + def test_no_login_attempts_without_credentials(self): + """Hits without username/password fields are not counted as login attempts.""" hits = [ self._create_hit(src_ip="8.8.8.8", hit_type="Cowrie"), self._create_hit(src_ip="8.8.8.8", hit_type="Cowrie"), @@ -492,6 +519,28 @@ def test_non_heralding_no_login_attempts(self): ioc = iocs[0] self.assertEqual(ioc.login_attempts, 0) + def test_mixed_hits_counts_only_credential_hits(self): + """Only hits with credentials are counted, regardless of honeypot type.""" + hits = [ + self._create_hit(src_ip="8.8.8.8", hit_type="Dionaea", username="admin", password="pass"), + self._create_hit(src_ip="8.8.8.8", hit_type="Dionaea"), + self._create_hit(src_ip="8.8.8.8", hit_type="Dionaea", username="root", password="root"), + ] + iocs = iocs_from_hits(hits) + ioc = iocs[0] + self.assertEqual(ioc.login_attempts, 2) + + def test_heralding_counts_login_attempts(self): + """Heralding hits with credentials are counted as login attempts.""" + hits = [ + self._create_hit(src_ip="8.8.8.8", hit_type="Heralding", username="root", password="1234"), + self._create_hit(src_ip="8.8.8.8", hit_type="Heralding", username="admin", password="admin"), + self._create_hit(src_ip="8.8.8.8", hit_type="Heralding", username="test", password="test"), + ] + iocs = iocs_from_hits(hits) + ioc = iocs[0] + self.assertEqual(ioc.login_attempts, 3) + def test_corrects_ip_reputation(self): MassScanner.objects.create(ip_address="8.8.8.8") hits = [self._create_hit(src_ip="8.8.8.8", ip_rep="known attacker")] From cc554564dbfa6c7daf4daa0e1633a78f17ad9f5f Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Mon, 2 Mar 2026 20:23:52 +0545 Subject: [PATCH 012/109] feat(api): Geo related field addition in the feed_response. Closes #524 (#909) * version(1): added geo field in api response Signed-off-by: Dorna Raj Gyawali * version(2): added only attacker_country --------- Signed-off-by: Dorna Raj Gyawali --- api/serializers.py | 1 + api/views/utils.py | 1 + tests/api/views/test_feeds_advanced_view.py | 17 +++++++++++++++++ tests/test_serializers.py | 1 + 4 files changed, 20 insertions(+) diff --git a/api/serializers.py b/api/serializers.py index dfb228a0..46a1b6f4 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -188,6 +188,7 @@ class FeedsResponseSerializer(serializers.Serializer): login_attempts = serializers.IntegerField(min_value=0) recurrence_probability = serializers.FloatField(min_value=0, max_value=1) expected_interactions = serializers.FloatField(min_value=0) + attacker_country = serializers.CharField(allow_null=True, allow_blank=True, max_length=120) def validate_feed_type(self, feed_type): logger.debug(f"FeedsResponseSerializer - validation feed_type: '{feed_type}'") diff --git a/api/views/utils.py b/api/views/utils.py index e8bcdcf7..607c52be 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -252,6 +252,7 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose "expected_interactions", "honeypots", # Always needed to calculate feed_type "destination_ports", # Always needed to calculate destination_port_count + "attacker_country", } # Additional verbose fields diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index 20ea7072..9bee16d3 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -78,3 +78,20 @@ def test_200_feeds_pagination_exclude_tor(self): def test_400_feeds_pagination(self): response = self.client.get("/api/feeds/advanced/?paginate=true&page_size=10&page=1&attack_type=test") self.assertEqual(response.status_code, 400) + + def test_200_feed_contains_attacker_country(self): + """ + Ensures that the response includes the attacker_country field. + """ + + # Setting attacker country for this IOC + self.ioc.attacker_country = "Nepal" + 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"], "Nepal") diff --git a/tests/test_serializers.py b/tests/test_serializers.py index e537b882..c22ef707 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -120,6 +120,7 @@ def test_valid_fields(self): "login_attempts": "0", "recurrence_probability": "0.1", "expected_interactions": "11.1", + "attacker_country": "Nepal", } serializer = FeedsResponseSerializer( data=data_, From 9918f7d829006421492d0803345bacf5fb38aa6c Mon Sep 17 00:00:00 2001 From: Usama Mohammed Elareeny Date: Tue, 3 Mar 2026 07:35:08 +0200 Subject: [PATCH 013/109] Disable submit buttons while submitting to avoid duplicate api request. Closes #903 (#915) * Fix auth forms: disable submit buttons while submitting to avoid duplicated api request * tests/awaited submission to settle before finishing the test + fixed some typos * using async waitFor to avoid a race test --- frontend/src/components/auth/Login.jsx | 2 +- frontend/src/components/auth/Register.jsx | 2 +- .../src/components/auth/ResetPassword.jsx | 2 +- .../src/components/auth/utils/EmailForm.jsx | 2 +- frontend/tests/components/auth/Login.test.jsx | 49 ++++++++++++ .../tests/components/auth/Register.test.jsx | 74 +++++++++++++++++++ .../components/auth/ResetPassword.test.jsx | 56 ++++++++++++++ .../components/auth/utils/EmailForm.test.jsx | 48 +++++++++++- 8 files changed, 230 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/auth/Login.jsx b/frontend/src/components/auth/Login.jsx index 1365cf3c..a943fb9c 100644 --- a/frontend/src/components/auth/Login.jsx +++ b/frontend/src/components/auth/Login.jsx @@ -128,7 +128,7 @@ function Login() { diff --git a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx index ebafda71..ad0f7580 100644 --- a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx +++ b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx @@ -256,4 +256,48 @@ describe("Enrichment Lookup Integration Tests", () => { expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); }); + + test("uses only formik isSubmitting for button state, no redundant loading state", async () => { + const user = userEvent.setup(); + + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }), + ); + + // Mock a delayed API response to catch in-flight state + axios.get.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => resolve({ data: { found: false, query: "1.1.1.1" } }), + 100, + ), + ), + ); + + render( + + + , + ); + + const inputElement = screen.getByLabelText("IP Address or Domain:"); + const submitButton = screen.getByRole("button", { name: /Search/i }); + + await user.type(inputElement, "1.1.1.1"); + await user.click(submitButton); + + // While request is in flight, button should say "Searching..." + expect( + screen.getByRole("button", { name: /Searching/i }), + ).toBeInTheDocument(); + expect(submitButton).toBeDisabled(); + + // After request completes, button should say "Search" again + await waitFor(() => { + expect( + screen.getByRole("button", { name: /^Search$/i }), + ).toBeInTheDocument(); + }); + }); }); From 6e5df77add79d79532b5e88971927b462c8c0c55 Mon Sep 17 00:00:00 2001 From: Krishna Awasthi <140143710+opbot-xd@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:55:21 +0530 Subject: [PATCH 021/109] expose tags in API responses and add tag-based filtering. Closes #522 (#899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: expose tags in API responses and add tag-based filtering * feat(api): expose tags in API responses and add tag-based filtering (#522) * refactor(migrations): rename 0038 to match upstream and add Tag.key index in 0040 - Rename 0038_tag.py → 0038_add_tag_model.py to match upstream/develop exactly - Remove db_index=True from 0038 (keep it identical to upstream) - Add 0040_tag_key_index.py: separate migration for Tag.key btree index on top of upstream's 0039 (GeoIP attacker_country/sensor.country) * refactor(feeds): replace _prefetch_tags with DB-level ArrayAgg(JSONObject) for tags - Remove _prefetch_tags helper (manual reimplementation of prefetch_related) - Annotate tags directly on the queryset using ArrayAgg(JSONObject(key, value, source)) with Q(tags__isnull=False) to skip IOCs without tags and distinct=True to prevent duplication from the general_honeypot JOIN - For paginated (list) paths: run a targeted ArrayAgg query on the slice IDs - Use annotation name tags_json to avoid conflict with the tags reverse FK on IOC - Remove redundant comments, keep only non-obvious ones Addresses code review feedback from regulartim. * refactor(feeds): move tag_key/tag_value out of FeedRequestParams Pass tag_key and tag_value as explicit kwargs to get_queryset instead of storing them in FeedRequestParams. They are read directly from request.query_params only in feeds_advanced, so the standard feeds endpoint can never trigger tag filtering regardless of what query params are passed. Removes enable_tag_filtering flag entirely. Suggested by regulartim. * refactor(feeds): move tags_json annotation to get_queryset - Annotate tags_json in get_queryset alongside honeypots annotation - Simplifies feeds_response by removing dual list/queryset handling - Add tags_json to repository methods (get_scanners_by_pks, get_recent_scanners) - Eliminates separate query for paginated IOCs Addresses regulartim feedback. * perf: optimize tags_json annotation and fix honeypots deduplication - Only annotate tags_json when format is JSON to avoid unnecessary JOINs and aggregation for txt/csv downloads - Add distinct=True to honeypots ArrayAgg in repository methods to prevent duplicate names when IOCs have multiple tags Addresses Copilot feedback. * Refactor: Remove `tags_json` annotation from IOC repository queries and adapt API views to conditionally include it and truncate tag query parameters. * refactor: remove redundant assignment of verbose and paginate parameters in advanced feed view. --- api/serializers.py | 14 +- api/views/feeds.py | 15 +- api/views/utils.py | 65 ++++-- greedybear/cronjobs/repositories/ioc.py | 4 +- greedybear/migrations/0040_alter_tag_key.py | 18 ++ greedybear/models.py | 2 +- tests/api/views/test_feeds_tags.py | 210 ++++++++++++++++++++ 7 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 greedybear/migrations/0040_alter_tag_key.py create mode 100644 tests/api/views/test_feeds_tags.py diff --git a/api/serializers.py b/api/serializers.py index 0adb7f2f..c23a2bba 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -6,7 +6,7 @@ from rest_framework import serializers from greedybear.consts import REGEX_DOMAIN -from greedybear.models import IOC, GeneralHoneypot +from greedybear.models import IOC, GeneralHoneypot, Tag from greedybear.utils import is_ip_address logger = logging.getLogger(__name__) @@ -20,8 +20,15 @@ def to_representation(self, value): return value.name +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ["key", "value", "source"] + + class IOCSerializer(serializers.ModelSerializer): general_honeypot = GeneralHoneypotSerializer(many=True, read_only=True) + tags = TagSerializer(many=True, read_only=True) class Meta: model = IOC @@ -49,7 +56,7 @@ def validate(self, data): raise serializers.ValidationError("Observable is not a valid IP address or domain") try: - required_object = IOC.objects.get(name=observable) + required_object = IOC.objects.prefetch_related("tags").get(name=observable) data["found"] = True data["ioc"] = required_object except IOC.DoesNotExist: @@ -125,6 +132,8 @@ class FeedsRequestSerializer(serializers.Serializer): verbose = serializers.ChoiceField(choices=["true", "false"]) paginate = serializers.ChoiceField(choices=["true", "false"]) format = serializers.ChoiceField(choices=["csv", "json", "txt"]) + tag_key = serializers.CharField(max_length=128, required=False, allow_blank=True) + tag_value = serializers.CharField(max_length=256, required=False, allow_blank=True) def validate_feed_type(self, feed_type): logger.debug(f"FeedsRequestSerializer - validation feed_type: '{feed_type}'") @@ -211,6 +220,7 @@ class FeedsResponseSerializer(serializers.Serializer): recurrence_probability = serializers.FloatField(min_value=0, max_value=1) expected_interactions = serializers.FloatField(min_value=0) attacker_country = serializers.CharField(allow_null=True, allow_blank=True, max_length=120) + tags = TagSerializer(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 37890f53..e45f0cb0 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -101,19 +101,28 @@ def feeds_advanced(request): ordering (str): Field to order results by, with optional `-` prefix for descending. (default: `-last_seen`) verbose (bool): `true` to include IOC properties that contain a lot of data, e.g. the list of days it was seen. (default: `false`) paginate (bool): `true` to paginate results. This forces the json format. (default: `false`) - format (str): Response format type. Besides `json`, `txt` and `csv` are supported but the response will only contain IOC values (e.g. IP adresses) without further information. (default: `json`) + format_ (str): Response format type. Besides `json`, `txt` and `csv` are supported but the response will only contain IOC values (e.g. IP addresses) without further information. (default: `json`) + tag_key (str, optional): Filter IOCs by tag key, e.g. `malware` or `confidence_of_abuse`. Only IOCs with at least one matching tag are returned. + tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring match), e.g. `mirai`. Can be used alone or combined with `tag_key`. Returns: Response: The HTTP response with formatted IOC data. """ logger.info(f"request /api/feeds/advanced/ with params: {request.query_params}") feed_params = FeedRequestParams(request.query_params) - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) verbose = feed_params.verbose == "true" paginate = feed_params.paginate == "true" if paginate: feed_params.format = "json" + valid_feed_types = get_valid_feed_types() + iocs_queryset = get_queryset( + request, + feed_params, + valid_feed_types, + tag_key=request.query_params.get("tag_key", "").strip(), + tag_value=request.query_params.get("tag_value", "").strip(), + ) + if paginate: paginator = CustomPageNumberPagination() iocs = paginator.paginate_queryset(iocs_queryset, request) resp_data = feeds_response(iocs, feed_params, valid_feed_types, dict_only=True, verbose=verbose) diff --git a/api/views/utils.py b/api/views/utils.py index 436fb6a0..04052f95 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -9,7 +9,8 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg from django.core.cache import cache -from django.db.models import Count, F, Max, Min, Q, Sum +from django.db.models import Count, F, Max, Min, Q, Sum, Value +from django.db.models.functions import JSONObject from django.http import HttpResponse, HttpResponseBadRequest, StreamingHttpResponse from rest_framework import status from rest_framework.response import Response @@ -128,7 +129,7 @@ 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): +def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, serializer_class=FeedsRequestSerializer, tag_key="", tag_value=""): """ Build a queryset to filter IOC data based on the request parameters. @@ -145,6 +146,8 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se - Allows injecting a custom serializer to enforce rules for specific feed types (e.g., to restrict ordering fields or validation for specialized feeds). - Default: `FeedsRequestSerializer`. + tag_key (str, optional): Filter IOCs by tag key. Only passed from feeds_advanced. + tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring). Only passed from feeds_advanced. Returns: QuerySet: The filtered queryset of IOC data. @@ -174,6 +177,11 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se if feed_params.include_reputation: query_dict["ip_reputation__in"] = feed_params.include_reputation + if tag_key: + query_dict["tags__key"] = tag_key[:128] # Truncate to Tag.key max_length + if tag_value: + query_dict["tags__value__icontains"] = tag_value[:256] # Truncate to Tag.value max_length + iocs = IOC.objects.filter(**query_dict).exclude(ip_reputation__in=feed_params.exclude_reputation).annotate(value=F("name")).distinct() # apply feed type filter as union; @@ -187,6 +195,17 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se if not is_aggregated: iocs = iocs.filter(general_honeypot__active=True) iocs = iocs.annotate(honeypots=ArrayAgg("general_honeypot__name", distinct=True)) + # Only annotate tags metadata when the response format needs it (e.g. JSON), + # to avoid unnecessary joins and aggregation work for txt/csv feeds. + if getattr(feed_params, "format", "").lower() == "json": + iocs = iocs.annotate( + tags_json=ArrayAgg( + JSONObject(key=F("tags__key"), value=F("tags__value"), source=F("tags__source")), + filter=Q(tags__isnull=False), + default=Value([]), + distinct=True, + ) + ) iocs = iocs.order_by(feed_params.ordering) iocs = iocs[: int(feed_params.feed_size)] @@ -246,7 +265,7 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose json_list = [] # Base fields always returned - base_fields = { + base_fields = ( "value", "first_seen", "last_seen", @@ -259,23 +278,34 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose "login_attempts", "recurrence_probability", "expected_interactions", - "honeypots", # Always needed to calculate feed_type - "destination_ports", # Always needed to calculate destination_port_count + "honeypots", # used to build feed_type; removed from response + "destination_ports", # used to calculate destination_port_count "attacker_country", - } + "tags", + ) - # Additional verbose fields - verbose_only_fields = { + verbose_only_fields = ( "days_seen", "firehol_categories", - } + ) + + required_fields = base_fields + verbose_only_fields if verbose else base_fields - # Fetch fields from database (always include honeypots and destination_ports) - required_fields = base_fields | verbose_only_fields if verbose else base_fields + # `tags_json` is annotated in get_queryset (only for JSON format) to avoid conflicting + # with the `tags` reverse FK on IOC. When the queryset comes from a repository method + # that does not annotate `tags_json` (e.g. the ML scoring path), exclude the field. + if isinstance(iocs, list): + has_tags_annotation = bool(iocs) and hasattr(iocs[0], "tags_json") + else: + has_tags_annotation = "tags_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) - # Collect values; `honeypots` will contain the list of associated honeypot names - iocs = (ioc_as_dict(ioc, required_fields) for ioc in iocs) if isinstance(iocs, list) else iocs.values(*required_fields) - for ioc in iocs: + iocs_iter: object + if isinstance(iocs, list): + iocs_iter = (ioc_as_dict(ioc, set(required_fields)) for ioc in iocs) + else: + iocs_iter = iocs.values(*required_fields).iterator(chunk_size=2000) + for ioc in iocs_iter: ioc_feed_type = [hp.lower() for hp in ioc.get("honeypots", []) if hp] data_ = ioc | { @@ -283,20 +313,17 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose "last_seen": ioc["last_seen"].strftime("%Y-%m-%d"), "feed_type": ioc_feed_type, "destination_port_count": len(ioc.get("destination_ports", [])), + "tags": ioc.pop("tags_json", []), } - # Remove verbose-only fields from response when not in verbose mode if not verbose: - # Remove destination_ports array from response data_.pop("destination_ports", None) - # Always remove honeypots field as it's redundant with feed_type data_.pop("honeypots", None) + data_.pop("id", None) - # Skip validation - data_ is constructed internally and matches the API contract json_list.append(data_) - # check if sorting the results by feed_type if feed_params.feed_type_sorting is not None: logger.info("Return feeds sorted by feed_type field") json_list = sorted( diff --git a/greedybear/cronjobs/repositories/ioc.py b/greedybear/cronjobs/repositories/ioc.py index ddeb1c9f..52171aed 100644 --- a/greedybear/cronjobs/repositories/ioc.py +++ b/greedybear/cronjobs/repositories/ioc.py @@ -190,7 +190,7 @@ def get_scanners_by_pks(self, primary_keys: set[int]): IOC.objects.filter(pk__in=primary_keys) .prefetch_related("general_honeypot") .annotate(value=F("name")) - .annotate(honeypots=ArrayAgg("general_honeypot__name")) + .annotate(honeypots=ArrayAgg("general_honeypot__name", distinct=True)) .values() ) @@ -213,7 +213,7 @@ def get_recent_scanners(self, cutoff_date, days_lookback: int = 30): .filter(last_seen__gte=cutoff_date, scanner=True) .prefetch_related("general_honeypot") .annotate(value=F("name")) - .annotate(honeypots=ArrayAgg("general_honeypot__name")) + .annotate(honeypots=ArrayAgg("general_honeypot__name", distinct=True)) .values() ) diff --git a/greedybear/migrations/0040_alter_tag_key.py b/greedybear/migrations/0040_alter_tag_key.py new file mode 100644 index 00000000..e414ff09 --- /dev/null +++ b/greedybear/migrations/0040_alter_tag_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-03 10:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0039_ioc_attacker_country_sensor_country_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='key', + field=models.CharField(db_index=True, max_length=128), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 738a5cf1..3415d7f0 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -191,7 +191,7 @@ class Tag(models.Model): """Tags for IOCs from enrichment sources like ThreatFox and AbuseIPDB.""" ioc = models.ForeignKey(IOC, on_delete=models.CASCADE, related_name="tags") - key = models.CharField(max_length=128) + key = models.CharField(max_length=128, db_index=True) value = models.CharField(max_length=256) source = models.CharField(max_length=64) # e.g., "threatfox", "abuseipdb" added = models.DateTimeField(db_default=Now()) diff --git a/tests/api/views/test_feeds_tags.py b/tests/api/views/test_feeds_tags.py new file mode 100644 index 00000000..e12858ef --- /dev/null +++ b/tests/api/views/test_feeds_tags.py @@ -0,0 +1,210 @@ +from rest_framework.test import APIClient + +from greedybear.models import Tag +from tests import CustomTestCase + + +class FeedsTagsTestCase(CustomTestCase): + """Tests for tag integration in feed responses.""" + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(user=self.superuser) + # Create tags for test IOCs + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + Tag.objects.create(ioc=self.ioc, key="threat_type", value="botnet_cc", source="threatfox") + Tag.objects.create(ioc=self.ioc_2, key="confidence_of_abuse", value="84%", source="abuseipdb") + + def test_200_feeds_advanced_includes_tags(self): + """Tags should appear in feeds_advanced JSON response.""" + 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("tags", target_ioc) + self.assertEqual(len(target_ioc["tags"]), 2) + + tag_keys = {t["key"] for t in target_ioc["tags"]} + self.assertIn("malware", tag_keys) + self.assertIn("threat_type", tag_keys) + + def test_200_feeds_advanced_tags_structure(self): + """Each tag dict should have key, value, source fields.""" + 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) + + for tag in target_ioc["tags"]: + self.assertIn("key", tag) + self.assertIn("value", tag) + self.assertIn("source", tag) + + def test_200_feeds_advanced_ioc_without_tags(self): + """IOCs without tags should have an empty tags list.""" + 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_domain.name), None) + self.assertIsNotNone(target_ioc) + self.assertIn("tags", target_ioc) + self.assertEqual(target_ioc["tags"], []) + + def test_200_feeds_advanced_no_id_in_response(self): + """The internal 'id' field should not leak into the API response.""" + response = self.client.get("/api/feeds/advanced/") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["iocs"] + for ioc in iocs: + self.assertNotIn("id", ioc) + + def test_200_filter_by_tag_key(self): + """Filtering by tag_key should return only IOCs with matching tags.""" + response = self.client.get("/api/feeds/advanced/?tag_key=malware") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["iocs"] + values = {i["value"] for i in iocs} + self.assertIn(self.ioc.name, values) + self.assertNotIn(self.ioc_domain.name, values) + + def test_200_filter_by_tag_value(self): + """Filtering by tag_value should use case-insensitive substring match.""" + response = self.client.get("/api/feeds/advanced/?tag_value=mirai") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["iocs"] + values = {i["value"] for i in iocs} + self.assertIn(self.ioc.name, values) + + def test_200_filter_by_tag_key_and_value(self): + """Filtering by both tag_key and tag_value should narrow results.""" + response = self.client.get("/api/feeds/advanced/?tag_key=malware&tag_value=Mirai") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["iocs"] + values = {i["value"] for i in iocs} + self.assertIn(self.ioc.name, values) + + def test_200_filter_by_tag_key_no_match(self): + """Filtering by a non-existent tag_key should return no IOCs.""" + response = self.client.get("/api/feeds/advanced/?tag_key=nonexistent") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["iocs"] + self.assertEqual(len(iocs), 0) + + def test_200_feeds_advanced_paginated_includes_tags(self): + """Tags should appear in paginated feeds_advanced response.""" + response = self.client.get("/api/feeds/advanced/?paginate=true&page_size=10&page=1") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["results"]["iocs"] + target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None) + self.assertIsNotNone(target_ioc) + self.assertIn("tags", target_ioc) + self.assertEqual(len(target_ioc["tags"]), 2) + + def test_401_feeds_advanced_unauthenticated(self): + """Unauthenticated requests should be rejected.""" + self.client.logout() + response = self.client.get("/api/feeds/advanced/") + self.assertEqual(response.status_code, 401) + + def test_200_public_feeds_ignores_tag_filter(self): + """Tag filtering should be ignored on the public feeds endpoint.""" + # Public endpoint should return all IOCs regardless of tag_key param + response = self.client.get("/api/feeds/?tag_key=nonexistent&page_size=10&page=1") + self.assertEqual(response.status_code, 200) + iocs = response.json()["results"]["iocs"] + # Should still return IOCs (filter not applied) + self.assertGreater(len(iocs), 0) + + def test_200_public_feeds_includes_tags(self): + """Public feeds endpoint should also include tags in JSON response.""" + response = self.client.get("/api/feeds/?page_size=10&page=1") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["results"]["iocs"] + target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None) + self.assertIsNotNone(target_ioc) + self.assertIn("tags", target_ioc) + self.assertEqual(len(target_ioc["tags"]), 2) + + def test_200_tags_do_not_bleed_between_iocs(self): + """Tags from one IOC should not appear on another IOC.""" + response = self.client.get("/api/feeds/advanced/") + self.assertEqual(response.status_code, 200) + + iocs = response.json()["iocs"] + ioc_1 = next((i for i in iocs if i["value"] == self.ioc.name), None) + ioc_2 = next((i for i in iocs if i["value"] == self.ioc_2.name), None) + self.assertIsNotNone(ioc_1) + self.assertIsNotNone(ioc_2) + + # ioc has malware + threat_type (2 tags), ioc_2 has confidence_of_abuse (1 tag) + self.assertEqual(len(ioc_1["tags"]), 2) + self.assertEqual(len(ioc_2["tags"]), 1) + ioc_1_keys = {t["key"] for t in ioc_1["tags"]} + ioc_2_keys = {t["key"] for t in ioc_2["tags"]} + self.assertNotIn("confidence_of_abuse", ioc_1_keys) + self.assertNotIn("malware", ioc_2_keys) + + def test_200_multi_source_tags_on_same_ioc(self): + """Tags from multiple sources should all appear on the same IOC.""" + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="90%", source="abuseipdb") + + 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) + # 2 threatfox tags + 1 abuseipdb tag + self.assertEqual(len(target_ioc["tags"]), 3) + sources = {t["source"] for t in target_ioc["tags"]} + self.assertEqual(sources, {"threatfox", "abuseipdb"}) + + +class EnrichmentTagsTestCase(CustomTestCase): + """Tests for tag integration in enrichment API responses.""" + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(user=self.superuser) + Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") + Tag.objects.create(ioc=self.ioc, key="confidence_of_abuse", value="84%", source="abuseipdb") + + def test_200_enrichment_includes_tags(self): + """Enrichment response should include tags for found IOC.""" + response = self.client.get(f"/api/enrichment?query={self.ioc.name}") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["found"]) + + ioc_data = response.json()["ioc"] + self.assertIn("tags", ioc_data) + self.assertEqual(len(ioc_data["tags"]), 2) + + def test_200_enrichment_tags_structure(self): + """Each tag in enrichment response should have key, value, source.""" + response = self.client.get(f"/api/enrichment?query={self.ioc.name}") + self.assertEqual(response.status_code, 200) + + tags = response.json()["ioc"]["tags"] + for tag in tags: + self.assertIn("key", tag) + self.assertIn("value", tag) + self.assertIn("source", tag) + + def test_200_enrichment_not_found_no_tags(self): + """Enrichment response for unfound IOC should have no ioc/tags data.""" + response = self.client.get("/api/enrichment?query=192.168.0.1") + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["found"]) + self.assertIsNone(response.json()["ioc"]) From acd0688a8a75fe590eb06b954e2e0e0a303f6b9b Mon Sep 17 00:00:00 2001 From: Manik Date: Wed, 4 Mar 2026 18:00:13 +0530 Subject: [PATCH 022/109] Add rate limiting to feeds endpoints. Closes #923 (#927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add rate limiting to feeds endpoints. * fix: use SimpleRateThrottle instead of ScopedRateThrottle ScopedRateThrottle.allow_request() always overwrites self.scope from view.throttle_scope, which our function-based views don't set. This caused scope to be None and all requests to bypass throttling entirely. Switch to SimpleRateThrottle (like DRF's built-in AnonRateThrottle and UserRateThrottle), which properly reads scope from the class attribute and applies throttling in __init__. * refactor: address review — scope cache.clear, env-configurable rates, unauth tests --------- Co-authored-by: Manik --- api/throttles.py | 32 ++++++++ api/views/feeds.py | 6 ++ docker/env_file_template | 4 + greedybear/settings.py | 5 ++ tests/api/views/test_feeds_advanced_view.py | 1 + tests/api/views/test_feeds_asn_view.py | 1 + tests/api/views/test_feeds_throttle.py | 88 +++++++++++++++++++++ 7 files changed, 137 insertions(+) create mode 100644 api/throttles.py create mode 100644 tests/api/views/test_feeds_throttle.py diff --git a/api/throttles.py b/api/throttles.py new file mode 100644 index 00000000..0c82e195 --- /dev/null +++ b/api/throttles.py @@ -0,0 +1,32 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +from rest_framework.throttling import SimpleRateThrottle + + +class FeedsThrottle(SimpleRateThrottle): + """Rate-limit for public (unauthenticated) feeds endpoints.""" + + scope = "feeds" + + def get_cache_key(self, request, view): + return self.cache_format % { + "scope": self.scope, + "ident": self.get_ident(request), + } + + +class FeedsAdvancedThrottle(SimpleRateThrottle): + """Rate-limit for authenticated feeds endpoints (advanced, asn).""" + + scope = "feeds_advanced" + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + + return self.cache_format % { + "scope": self.scope, + "ident": ident, + } diff --git a/api/views/feeds.py b/api/views/feeds.py index e45f0cb0..d5b51eb6 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -8,11 +8,13 @@ api_view, authentication_classes, permission_classes, + throttle_classes, ) from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from api.serializers import ASNFeedsOrderingSerializer +from api.throttles import FeedsAdvancedThrottle, FeedsThrottle from api.views.utils import ( FeedRequestParams, asn_aggregated_queryset, @@ -26,6 +28,7 @@ @api_view([GET]) +@throttle_classes([FeedsThrottle]) def feeds(request, feed_type, attack_type, prioritize, format_): """ Handle requests for IOC feeds with specific parameters and format the response accordingly. @@ -56,6 +59,7 @@ def feeds(request, feed_type, attack_type, prioritize, format_): @api_view([GET]) +@throttle_classes([FeedsThrottle]) def feeds_pagination(request): """ Handle requests for paginated IOC feeds based on query parameters. @@ -85,6 +89,7 @@ def feeds_pagination(request): @api_view([GET]) @authentication_classes([CookieTokenAuthentication]) @permission_classes([IsAuthenticated]) +@throttle_classes([FeedsAdvancedThrottle]) def feeds_advanced(request): """ Handle requests for IOC feeds based on query parameters and format the response accordingly. @@ -133,6 +138,7 @@ def feeds_advanced(request): @api_view(["GET"]) @authentication_classes([CookieTokenAuthentication]) @permission_classes([IsAuthenticated]) +@throttle_classes([FeedsAdvancedThrottle]) def feeds_asn(request): """ Retrieve aggregated IOC feed data grouped by ASN (Autonomous System Number). diff --git a/docker/env_file_template b/docker/env_file_template index a234678e..0af03a60 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -75,6 +75,10 @@ THREATFOX_API_KEY = # Get your free API key from https://www.abuseipdb.com/ 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 + # Optional feed license URL to include in API responses # If not set, no license information will be included in feeds # Example: https://github.com/honeynet/GreedyBear/blob/main/FEEDS_LICENSE.md diff --git a/greedybear/settings.py b/greedybear/settings.py index fdef5a44..5c216a55 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -106,6 +106,11 @@ # Pagination "DEFAULT_PAGINATION_CLASS": "certego_saas.ext.pagination.CustomPageNumberPagination", "PAGE_SIZE": 10, + # Throttling + "DEFAULT_THROTTLE_RATES": { + "feeds": os.environ.get("FEEDS_THROTTLE_RATE", "30/minute"), + "feeds_advanced": os.environ.get("FEEDS_ADVANCED_THROTTLE_RATE", "100/minute"), + }, } # Django-Rest-Durin diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index 9bee16d3..5fcb2a31 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -6,6 +6,7 @@ class FeedsAdvancedViewTestCase(CustomTestCase): def setUp(self): + super().setUp() self.client = APIClient() self.client.force_authenticate(user=self.superuser) diff --git a/tests/api/views/test_feeds_asn_view.py b/tests/api/views/test_feeds_asn_view.py index 68e10244..c50557f5 100644 --- a/tests/api/views/test_feeds_asn_view.py +++ b/tests/api/views/test_feeds_asn_view.py @@ -61,6 +61,7 @@ def setUpClass(cls): cls.ioc_low.save() def setUp(self): + super().setUp() self.client = APIClient() self.client.force_authenticate(user=self.superuser) self.url = "/api/feeds/asn/" diff --git a/tests/api/views/test_feeds_throttle.py b/tests/api/views/test_feeds_throttle.py new file mode 100644 index 00000000..c69e9c53 --- /dev/null +++ b/tests/api/views/test_feeds_throttle.py @@ -0,0 +1,88 @@ +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) From b01e3e04ab8a0db3170ffd1091969c59913145b6 Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:00:19 +0100 Subject: [PATCH 023/109] Bump rollup from 4.57.1 to 4.59.0 --- frontend/package-lock.json | 455 ++++++++++++++++++++----------------- 1 file changed, 247 insertions(+), 208 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92864837..b3f63ac7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1865,9 +1865,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1879,9 +1879,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1893,9 +1893,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1907,9 +1907,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1921,9 +1921,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1935,9 +1935,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1949,13 +1949,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1963,13 +1966,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1977,13 +1983,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1991,13 +2000,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2005,13 +2017,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2019,13 +2034,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2033,13 +2051,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2047,13 +2068,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2061,13 +2085,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2075,13 +2102,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2089,13 +2119,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2103,13 +2136,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2117,13 +2153,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2131,9 +2170,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -2145,9 +2184,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2159,9 +2198,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2173,9 +2212,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2187,9 +2226,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -2201,9 +2240,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -7552,9 +7591,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -7568,31 +7607,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -10489,177 +10528,177 @@ "dev": true }, "@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "dev": true, "optional": true }, "@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "dev": true, "optional": true }, "@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "dev": true, "optional": true }, "@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "dev": true, "optional": true }, "@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "dev": true, "optional": true }, "@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "dev": true, "optional": true }, "@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "dev": true, "optional": true }, "@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "dev": true, "optional": true }, @@ -14495,36 +14534,36 @@ } }, "rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "@types/estree": "1.0.8", "fsevents": "~2.3.2" } From 06a17edc74a5d35b1d14ceeedc01b5d0be654000 Mon Sep 17 00:00:00 2001 From: Paolo Scarlata <179048129+ZGr3Y@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:44:23 +0100 Subject: [PATCH 024/109] Chore: Add missing rel attributes to target="_blank" links. Closes #912 (#920) * Chore: Add missing rel="noopener noreferrer" to target="_blank" links for ESLint compliance * add "noopener" to line 138 in tableColumns.jsx * fix(frontend): prettier formatting for tableColumns.jsx * Delete fix-missing-rel-attributes.patch --- frontend/src/components/auth/utils/registration-alert.jsx | 6 +++++- frontend/src/components/feeds/Feeds.jsx | 2 ++ frontend/src/components/feeds/tableColumns.jsx | 2 +- frontend/src/layouts/widget/UserMenu.jsx | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/auth/utils/registration-alert.jsx b/frontend/src/components/auth/utils/registration-alert.jsx index 3c4b91c0..ce85abc7 100644 --- a/frontend/src/components/auth/utils/registration-alert.jsx +++ b/frontend/src/components/auth/utils/registration-alert.jsx @@ -113,7 +113,11 @@ export function ConfigurationModalAlert(props) {

If you are an admin please check the{" "} - + documentation {" "} and correctly configure all the required variables. diff --git a/frontend/src/components/feeds/Feeds.jsx b/frontend/src/components/feeds/Feeds.jsx index b98a5b92..0c481205 100644 --- a/frontend/src/components/feeds/Feeds.jsx +++ b/frontend/src/components/feeds/Feeds.jsx @@ -171,6 +171,7 @@ export default function Feeds() { outline href={FEEDS_LICENSE} target="_blank" + rel="noopener noreferrer" >  Feeds license @@ -331,6 +332,7 @@ export default function Feeds() { outline href={feedsState.url} target="_blank" + rel="noopener noreferrer" >  Raw data diff --git a/frontend/src/components/feeds/tableColumns.jsx b/frontend/src/components/feeds/tableColumns.jsx index 23b6a461..6666808c 100644 --- a/frontend/src/components/feeds/tableColumns.jsx +++ b/frontend/src/components/feeds/tableColumns.jsx @@ -136,7 +136,7 @@ const feedsTableColumns = [ {/* Django Admin Interface */} - + Django Admin Interface {/* API Access/Sessions */} From f1d6b3c0105576ddde66537c56f1fa2bf462cd1f Mon Sep 17 00:00:00 2001 From: armoredvortex <66690593+armoredvortex@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:50:34 +0530 Subject: [PATCH 025/109] Add button to reset filters on Feeds page. Closes #935 (#943) * add reset filter button for feeds page * add tests for reset filter button * aria-label for reset filters button and fix filter reset test * fix typo in aria label --- frontend/src/components/feeds/Feeds.jsx | 39 +++++++--- .../tests/components/feeds/Feeds.test.jsx | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/feeds/Feeds.jsx b/frontend/src/components/feeds/Feeds.jsx index 0c481205..fc018c3f 100644 --- a/frontend/src/components/feeds/Feeds.jsx +++ b/frontend/src/components/feeds/Feeds.jsx @@ -2,6 +2,7 @@ import React from "react"; import { Container, Button, Col, Label, FormGroup, Row } from "reactstrap"; import { VscJson } from "react-icons/vsc"; import { TbLicense } from "react-icons/tb"; +import { MdFilterAltOff } from "react-icons/md"; import { useLocation } from "react-router-dom"; import { FEEDS_BASE_URI, GENERAL_HONEYPOT_URI } from "../../constants/api"; import { @@ -114,6 +115,12 @@ export default function Feeds() { // feedsData is lifted from FeedsTable so we can show the count in the header const [feedsData, setFeedsData] = React.useState(null); + const isDefault = + feedsState.tableParams.feed_type === DEFAULT_VALUES.feeds_type && + feedsState.tableParams.attack_type === DEFAULT_VALUES.attack_type && + feedsState.tableParams.ioc_type === DEFAULT_VALUES.ioc_type && + feedsState.tableParams.prioritize === DEFAULT_VALUES.prioritize; + // API to extract general honeypot const [honeypots, Loader] = useAxiosComponentLoader({ url: `${GENERAL_HONEYPOT_URI}?onlyActive=true`, @@ -191,8 +198,8 @@ export default function Feeds() { {(formik) => { return (

- - + + ); @@ -321,13 +343,8 @@ export default function Feeds() { )} /> - +
{/* Footer */} - - - {VERSION} - - - + + + + + Follow us on: + - Follow @intel_owl + + + + + + + + + + + + {VERSION}
diff --git a/frontend/src/styles/App.scss b/frontend/src/styles/App.scss index 29762562..bb1c1c8d 100644 --- a/frontend/src/styles/App.scss +++ b/frontend/src/styles/App.scss @@ -100,22 +100,6 @@ section.fixed-bottom { overflow-y: auto; } -.twitter-follow-button { - /* stylelint-disable scss/at-extend-no-missing-placeholder */ - @extend .btn; - @extend .btn-xs; - /* stylelint-enable scss/at-extend-no-missing-placeholder */ - - background-color: #1da1f2; - color: $light; - font-weight: 450; - - &:hover { - background-color: shade-color(#1da1f2, 20%); - color: $light !important; - } -} - .badge-top-end-corner { top: -1.25em; right: 0.75em; From 9b2e124f200dbd07c125fca537299626457a9879 Mon Sep 17 00:00:00 2001 From: Abhijeet Sapar <139008505+Abhijeet17o@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:12:35 +0530 Subject: [PATCH 041/109] tests: add test coverage for greedybear/slack.py. Closes #977 (#1000) Adds SendSlackMessageTests covering: - No token configured: logs warning and skips WebClient call - Successful message send: verifies WebClient is called with correct args - Exception handling: logs error but does not re-raise Closes #977 --- tests/test_slack.py | 49 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/test_slack.py diff --git a/tests/test_slack.py b/tests/test_slack.py new file mode 100644 index 00000000..2f723aee --- /dev/null +++ b/tests/test_slack.py @@ -0,0 +1,49 @@ +from unittest.mock import MagicMock, patch + +from django.test import override_settings + +from greedybear.slack import send_slack_message +from tests import CustomTestCase + +TEST_LOGGING = { + "version": 1, + "disable_existing_loggers": True, +} + + +@override_settings(LOGGING=TEST_LOGGING) +class SendSlackMessageTests(CustomTestCase): + @override_settings(SLACK_TOKEN="") + @patch("greedybear.slack.WebClient") + @patch("greedybear.slack.logger") + def test_no_token_configured_logs_warning_and_skips(self, mock_logger, mock_webclient): + send_slack_message("hello") + + mock_webclient.assert_not_called() + mock_logger.warning.assert_called_once_with("Slack is not configured, message not sent") + + @override_settings(SLACK_TOKEN="xoxb-test-token", DEFAULT_SLACK_CHANNEL="#alerts") + @patch("greedybear.slack.WebClient") + @patch("greedybear.slack.logger") + def test_message_sent_successfully(self, mock_logger, mock_webclient): + mock_client = MagicMock() + mock_webclient.return_value = mock_client + + send_slack_message("test alert") + + mock_webclient.assert_called_once_with(token="xoxb-test-token") + mock_client.chat_postMessage.assert_called_once_with(channel="#alerts", text="test alert", mrkdwn=True) + mock_logger.exception.assert_not_called() + + @override_settings(SLACK_TOKEN="xoxb-test-token", DEFAULT_SLACK_CHANNEL="#alerts") + @patch("greedybear.slack.WebClient") + @patch("greedybear.slack.logger") + def test_exception_is_logged_but_not_raised(self, mock_logger, mock_webclient): + error = Exception("Slack API error") + mock_client = MagicMock() + mock_client.chat_postMessage.side_effect = error + mock_webclient.return_value = mock_client + + send_slack_message("test alert") + + mock_logger.exception.assert_called_once_with(error) From b0ba9216651050d7b10bed79f0f1e536750f2218 Mon Sep 17 00:00:00 2001 From: Rahul Guwani Date: Tue, 10 Mar 2026 12:43:51 +0530 Subject: [PATCH 042/109] feature: normalize credentials into separate Credential model. Closes #668 (#902) * feature: normalize credentials into separate Credential model. Closes #668 * test: update test_process_session_hit_login_failed for ManyToMany credentials * fix: add missing blank line between classes in models.py * fix: address review feedback on credential normalization - Replace unique_together with UniqueConstraint in Credential model - Optimize data migration using iterator() and bulk_create() - Remove login_attempts increment (removed in recent PR) - Update test to reflect new ManyToMany credential behaviour * fix: remove extra blank lines flagged by Ruff * fix: restore blank line between match cases in cowrie.py * fix: remove whitespace from blank line in cowrie.py * fix: remove whitespace from blank line in cowrie.py * fix: add whitespace from blank line in cowrie.py * fix: correct import order in migration file * fix: optimize migration with PostgreSQL unnest, add credentials prefetch, move credential creation to repository * fix: rename migration to 0042, add migration test for credential normalization --------- Co-authored-by: rahul-software-dev <24f3003169@ds.study.iitm.ac.in> --- api/views/cowrie_session.py | 11 ++- greedybear/admin.py | 18 ++++- .../cronjobs/extraction/strategies/cowrie.py | 2 +- .../cronjobs/repositories/cowrie_session.py | 14 ++++ ...042_credential_model_and_data_migration.py | 75 +++++++++++++++++++ greedybear/models.py | 20 ++++- tests/__init__.py | 7 +- tests/test_cowrie_extraction.py | 4 +- tests/test_migrations.py | 30 ++++++++ tests/test_models.py | 2 +- 10 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 greedybear/migrations/0042_credential_model_and_data_migration.py diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index aea2edc0..b2d12da6 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -1,6 +1,5 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. -import itertools import logging import socket @@ -80,7 +79,7 @@ def cowrie_session_view(request): return HttpResponseBadRequest("Missing required 'query' parameter") if is_ip_address(observable): - sessions = CowrieSession.objects.filter(source__name=observable, duration__gt=0).prefetch_related("source", "commands") + sessions = CowrieSession.objects.filter(source__name=observable, duration__gt=0).prefetch_related("source", "commands", "credentials") if not sessions.exists(): raise Http404(f"No information found for IP: {observable}") @@ -89,14 +88,14 @@ def cowrie_session_view(request): commands = CommandSequence.objects.get(commands_hash=observable.lower()) except CommandSequence.DoesNotExist as exc: raise Http404(f"No command sequences found with hash: {observable}") from exc - sessions = CowrieSession.objects.filter(commands=commands, duration__gt=0).prefetch_related("source", "commands") + sessions = CowrieSession.objects.filter(commands=commands, duration__gt=0).prefetch_related("source", "commands", "credentials") else: return HttpResponseBadRequest("Query must be a valid IP address or SHA-256 hash") 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") + related_sessions = CowrieSession.objects.filter(commands__cluster__in=clusters).prefetch_related("source", "commands", "credentials") sessions = sessions.union(related_sessions) response_data = { @@ -109,7 +108,7 @@ def cowrie_session_view(request): response_data["commands"] = sorted("\n".join(cmd.commands) for cmd in unique_commands) response_data["sources"] = sorted({s.source.name for s in sessions}, key=socket.inet_aton) if include_credentials: - response_data["credentials"] = sorted(set(itertools.chain(*[s.credentials for s in sessions]))) + response_data["credentials"] = sorted({str(c) for s in sessions for c in s.credentials.all()}) if include_session_data: response_data["sessions"] = [ { @@ -117,7 +116,7 @@ def cowrie_session_view(request): "duration": s.duration, "source": s.source.name, "interactions": s.interaction_count, - "credentials": s.credentials if s.credentials else [], + "credentials": [str(c) for c in s.credentials.all()], "commands": "\n".join(s.commands.commands) if s.commands else "", } for s in sessions diff --git a/greedybear/admin.py b/greedybear/admin.py index 84c27260..0ba94e64 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -10,6 +10,7 @@ IOC, CommandSequence, CowrieSession, + Credential, FireHolList, GeneralHoneypot, MassScanner, @@ -73,7 +74,7 @@ class SessionInline(admin.TabularInline): "source", "start_time", "duration", - "credentials", + "credential_list", "interaction_count", "commands", ] @@ -82,6 +83,9 @@ class SessionInline(admin.TabularInline): extra = 0 ordering = ["-start_time"] + def credential_list(self, session): + return ", ".join([str(c) for c in session.credentials.all()]) + @admin.register(CowrieSession) class CowrieSessionModelAdmin(admin.ModelAdmin): @@ -90,7 +94,7 @@ class CowrieSessionModelAdmin(admin.ModelAdmin): "start_time", "duration", "login_attempt", - "credentials", + "credential_list", "command_execution", "interaction_count", "source", @@ -100,6 +104,16 @@ class CowrieSessionModelAdmin(admin.ModelAdmin): raw_id_fields = ["source", "commands"] list_filter = ["login_attempt", "command_execution"] + def credential_list(self, session): + return ", ".join([str(c) for c in session.credentials.all()]) + + +@admin.register(Credential) +class CredentialModelAdmin(admin.ModelAdmin): + list_display = ["username", "password"] + search_fields = ["username", "password"] + search_help_text = ["search for username or password"] + @admin.register(CommandSequence) class CommandSequenceModelAdmin(admin.ModelAdmin): diff --git a/greedybear/cronjobs/extraction/strategies/cowrie.py b/greedybear/cronjobs/extraction/strategies/cowrie.py index cbea597a..fe741de2 100644 --- a/greedybear/cronjobs/extraction/strategies/cowrie.py +++ b/greedybear/cronjobs/extraction/strategies/cowrie.py @@ -251,7 +251,7 @@ def _process_session_hit(self, session_record: CowrieSession, hit: dict, ioc: IO session_record.login_attempt = True username = normalize_credential_field(hit["username"]) password = normalize_credential_field(hit["password"]) - session_record.credentials.append(f"{username} | {password}") + self.session_repo.add_credential(session_record, username, password) case "cowrie.command.input": self.log.info(f"found a command execution from {ioc.name}") diff --git a/greedybear/cronjobs/repositories/cowrie_session.py b/greedybear/cronjobs/repositories/cowrie_session.py index 5715ac91..1f816bba 100644 --- a/greedybear/cronjobs/repositories/cowrie_session.py +++ b/greedybear/cronjobs/repositories/cowrie_session.py @@ -122,3 +122,17 @@ def delete_sessions_without_commands(self, cutoff_date) -> int: """ deleted_count, _ = CowrieSession.objects.filter(start_time__lte=cutoff_date, commands__isnull=True).delete() return deleted_count + + def add_credential(self, session: CowrieSession, username: str, password: str) -> None: + """ + Get or create a Credential and associate it with the session. + + Args: + session: The CowrieSession instance to associate the credential with. + username: The credential username. + password: The credential password. + """ + from greedybear.models import Credential + + credential, _ = Credential.objects.get_or_create(username=username, password=password) + session.credentials.add(credential) diff --git a/greedybear/migrations/0042_credential_model_and_data_migration.py b/greedybear/migrations/0042_credential_model_and_data_migration.py new file mode 100644 index 00000000..b5c3a08b --- /dev/null +++ b/greedybear/migrations/0042_credential_model_and_data_migration.py @@ -0,0 +1,75 @@ +""" +Migration to replace the credentials ArrayField on CowrieSession with +a normalized Credential model using a ManyToMany relationship. +""" +from django.db import migrations, models + + +def migrate_credentials(apps, schema_editor): + schema_editor.execute(""" + INSERT INTO greedybear_credential (username, password) + SELECT DISTINCT + split_part(cred, ' | ', 1), + split_part(cred, ' | ', 2) + FROM greedybear_cowriesession, unnest(old_credentials) AS cred + WHERE cred LIKE '%% | %%' + ON CONFLICT DO NOTHING; + """) + + schema_editor.execute(""" + INSERT INTO greedybear_cowriesession_credentials (cowriesession_id, credential_id) + SELECT DISTINCT s.session_id, c.id + FROM greedybear_cowriesession s, unnest(s.old_credentials) AS cred + JOIN greedybear_credential c + ON c.username = split_part(cred, ' | ', 1) + AND c.password = split_part(cred, ' | ', 2) + WHERE cred LIKE '%% | %%' + ON CONFLICT DO NOTHING; + """) + + +class Migration(migrations.Migration): + + dependencies = [ + ("greedybear", "0041_sharetoken"), + ] + + operations = [ + # Step 1: Rename old ArrayField to preserve existing data + migrations.RenameField( + model_name="cowriesession", + old_name="credentials", + new_name="old_credentials", + ), + # Step 2: Create new Credential model + migrations.CreateModel( + name="Credential", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("username", models.CharField(max_length=256)), + ("password", models.CharField(max_length=256)), + ], + options={ + "indexes": [ + models.Index(fields=["username"], name="greedybear__usernam_29c9d6_idx"), + models.Index(fields=["password"], name="greedybear__passwor_6a8f16_idx"), + ], + "constraints": [ + models.UniqueConstraint(fields=["username", "password"], name="unique_credential"), + ], + }, + ), + # Step 3: Add new ManyToMany credentials field + migrations.AddField( + model_name="cowriesession", + name="credentials", + field=models.ManyToManyField(blank=True, to="greedybear.credential"), + ), + # Step 4: Migrate data from old_credentials into Credential objects + migrations.RunPython(migrate_credentials, reverse_code=migrations.RunPython.noop), + # Step 5: Remove old ArrayField now that data is migrated + migrations.RemoveField( + model_name="cowriesession", + name="old_credentials", + ), + ] \ No newline at end of file diff --git a/greedybear/models.py b/greedybear/models.py index 8610bb3a..b9d3a101 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -110,15 +110,27 @@ def __str__(self): return cmd_string[:29] + "..." if len(cmd_string) > 32 else cmd_string +class Credential(models.Model): + username = models.CharField(max_length=256, blank=False) + password = models.CharField(max_length=256, blank=False) + + class Meta: + constraints = [models.UniqueConstraint(fields=["username", "password"], name="unique_credential")] + indexes = [ + models.Index(fields=["username"]), + models.Index(fields=["password"]), + ] + + def __str__(self): + return f"{self.username} | {self.password}" + + class CowrieSession(models.Model): session_id = models.BigIntegerField(primary_key=True) start_time = models.DateTimeField(blank=True, null=True) duration = models.FloatField(blank=True, null=True) login_attempt = models.BooleanField(default=False) - credentials = pg_fields.ArrayField( - models.CharField(max_length=256, blank=True), - default=list, - ) + credentials = models.ManyToManyField(Credential, blank=True) command_execution = models.BooleanField(default=False) interaction_count = models.IntegerField(default=0) source = models.ForeignKey(IOC, on_delete=models.CASCADE) diff --git a/tests/__init__.py b/tests/__init__.py index b22f228b..0555535e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -10,6 +10,7 @@ IOC, CommandSequence, CowrieSession, + Credential, GeneralHoneypot, IocType, ) @@ -142,12 +143,13 @@ def setUpTestData(cls): start_time=cls.current_time, duration=1.234, login_attempt=True, - credentials=["root | root"], command_execution=True, interaction_count=5, source=cls.ioc, commands=cls.command_sequence, ) + credential, _ = Credential.objects.get_or_create(username="root", password="root") + cls.cowrie_session.credentials.add(credential) cls.cowrie_session.save() cls.cmd_seq_2 = ["cd bar", "ls -la"] @@ -165,12 +167,13 @@ def setUpTestData(cls): start_time=cls.current_time, duration=2.234, login_attempt=True, - credentials=["user | user"], command_execution=True, interaction_count=5, source=cls.ioc_2, commands=cls.command_sequence_2, ) + credential_2, _ = Credential.objects.get_or_create(username="user", password="user") + cls.cowrie_session_2.credentials.add(credential_2) cls.cowrie_session_2.save() try: diff --git a/tests/test_cowrie_extraction.py b/tests/test_cowrie_extraction.py index ab5acdbb..50098ed8 100644 --- a/tests/test_cowrie_extraction.py +++ b/tests/test_cowrie_extraction.py @@ -226,7 +226,7 @@ def test_process_session_hit_connect(self): def test_process_session_hit_login_failed(self): """Test processing of login failure event.""" session_record = Mock() - session_record.credentials = [] + session_record.credentials = Mock() session_record.source = Mock(login_attempts=0) session_record.interaction_count = 0 @@ -241,7 +241,7 @@ def test_process_session_hit_login_failed(self): self.strategy._process_session_hit(session_record, hit, ioc) self.assertTrue(session_record.login_attempt) - self.assertIn("root | password123", session_record.credentials) + self.mock_session_repo.add_credential.assert_called_once_with(session_record, "root", "password123") def test_process_session_hit_command_input(self): """Test processing of command input event.""" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 610317a5..7553cbc6 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -119,3 +119,33 @@ def test_log4pot_kept_if_has_iocs(self): hp_new.objects.filter(name="Log4pot").exists(), "Log4pot with IOCs should NOT be deleted", ) + + +@tag("migration") +class TestCredentialModelMigration(MigrationTestCase): + """Tests that credentials are correctly migrated from ArrayField to Credential model.""" + + migrate_from = "0041_sharetoken" + migrate_to = "0042_credential_model_and_data_migration" + + def test_credentials_migrated_to_credential_model(self): + IOC = self.old_state.apps.get_model(self.app_name, "IOC") + CowrieSession = self.old_state.apps.get_model(self.app_name, "CowrieSession") + + ioc = IOC.objects.create(name="1.2.3.4", type="ip") + session = CowrieSession.objects.create( + session_id=1, + source=ioc, + credentials=["root | password123", "admin | admin"], + ) + + new_state = self.apply_tested_migration() + Credential = new_state.apps.get_model(self.app_name, "Credential") + CowrieSession = new_state.apps.get_model(self.app_name, "CowrieSession") + + self.assertEqual(Credential.objects.count(), 2) + self.assertTrue(Credential.objects.filter(username="root", password="password123").exists()) + self.assertTrue(Credential.objects.filter(username="admin", password="admin").exists()) + + session = CowrieSession.objects.get(session_id=1) + self.assertEqual(session.credentials.count(), 2) diff --git a/tests/test_models.py b/tests/test_models.py index 2fa4a212..4e1d429d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -43,7 +43,7 @@ def test_cowrie_session_model(self): self.assertEqual(self.cowrie_session.start_time, self.current_time) self.assertEqual(self.cowrie_session.duration, 1.234) self.assertEqual(self.cowrie_session.login_attempt, True) - self.assertEqual(self.cowrie_session.credentials, ["root | root"]) + self.assertEqual(str(self.cowrie_session.credentials.first()), "root | root") self.assertEqual(self.cowrie_session.command_execution, True) self.assertEqual(self.cowrie_session.interaction_count, 5) self.assertEqual(self.cowrie_session.source.name, "140.246.171.141") From b0d85d1a0ffe48661adfbc4ece262eba58ed781d Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:34:24 +0100 Subject: [PATCH 043/109] Truncate credentials in data migration to prevent varchar overflow --- .../0042_credential_model_and_data_migration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/greedybear/migrations/0042_credential_model_and_data_migration.py b/greedybear/migrations/0042_credential_model_and_data_migration.py index b5c3a08b..30898189 100644 --- a/greedybear/migrations/0042_credential_model_and_data_migration.py +++ b/greedybear/migrations/0042_credential_model_and_data_migration.py @@ -9,8 +9,8 @@ def migrate_credentials(apps, schema_editor): schema_editor.execute(""" INSERT INTO greedybear_credential (username, password) SELECT DISTINCT - split_part(cred, ' | ', 1), - split_part(cred, ' | ', 2) + LEFT(split_part(cred, ' | ', 1), 256), + LEFT(split_part(cred, ' | ', 2), 256) FROM greedybear_cowriesession, unnest(old_credentials) AS cred WHERE cred LIKE '%% | %%' ON CONFLICT DO NOTHING; @@ -21,8 +21,8 @@ def migrate_credentials(apps, schema_editor): SELECT DISTINCT s.session_id, c.id FROM greedybear_cowriesession s, unnest(s.old_credentials) AS cred JOIN greedybear_credential c - ON c.username = split_part(cred, ' | ', 1) - AND c.password = split_part(cred, ' | ', 2) + ON c.username = LEFT(split_part(cred, ' | ', 1), 256) + AND c.password = LEFT(split_part(cred, ' | ', 2), 256) WHERE cred LIKE '%% | %%' ON CONFLICT DO NOTHING; """) From bebf47450893b39256db17b9b9e74bf21d5fdc9e Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:28:20 +0100 Subject: [PATCH 044/109] Remove country code from AbuseIPDB enrichment / Tags (#1001) * remove country code from enrichment logic * adapt tests * fix doc string --- greedybear/cronjobs/abuseipdb_feed.py | 35 +++++++++------------------ tests/test_abuseipdb.py | 11 --------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/greedybear/cronjobs/abuseipdb_feed.py b/greedybear/cronjobs/abuseipdb_feed.py index 26905eb0..4db2e7db 100644 --- a/greedybear/cronjobs/abuseipdb_feed.py +++ b/greedybear/cronjobs/abuseipdb_feed.py @@ -69,38 +69,30 @@ def run(self) -> None: self.log.info(f"Retrieved {len(blocklist_data)} IPs from AbuseIPDB blocklist") # Parse feed into a dict keyed by IP - feed_by_ip = self._parse_feed(blocklist_data) - self.log.info(f"Parsed {len(feed_by_ip)} valid IPs from AbuseIPDB feed") + score_by_ip = self._parse_feed(blocklist_data) + self.log.info(f"Parsed {len(score_by_ip)} valid IPs from AbuseIPDB feed") - if not feed_by_ip: + if not score_by_ip: # No valid IPs found — clear stale tags and return self.tag_repo.replace_tags_for_source(SOURCE_NAME, []) return # Join against IOC table: find IOCs whose name matches feed IPs - matching_iocs = IOC.objects.filter(name__in=feed_by_ip.keys()).values_list("id", "name") + matching_iocs = IOC.objects.filter(name__in=score_by_ip.keys()).values_list("id", "name") # Build tag entries for matching IOCs tag_entries = [] matched_count = 0 for ioc_id, ioc_name in matching_iocs: matched_count += 1 - enrichment = feed_by_ip[ioc_name] + score = score_by_ip[ioc_name] - if enrichment.get("abuse_confidence_score") is not None: + if score is not None: tag_entries.append( { "ioc_id": ioc_id, "key": "confidence_of_abuse", - "value": f"{enrichment['abuse_confidence_score']}%", - } - ) - if enrichment.get("country_code"): - tag_entries.append( - { - "ioc_id": ioc_id, - "key": "country_code", - "value": enrichment["country_code"], + "value": f"{score}%", } ) @@ -112,7 +104,7 @@ def run(self) -> None: self.log.error(f"Failed to fetch AbuseIPDB blocklist: {e}") raise - def _parse_feed(self, blocklist_data: list) -> dict[str, dict]: + def _parse_feed(self, blocklist_data: list) -> dict[str, int]: """ Parse AbuseIPDB blocklist data into a dict keyed by validated IP address. @@ -120,9 +112,9 @@ def _parse_feed(self, blocklist_data: list) -> dict[str, dict]: blocklist_data: Raw blocklist data from AbuseIPDB API. Returns: - Dict mapping IP address -> enrichment dict. + Dict mapping IP address -> abuse confidence score. """ - feed_by_ip: dict[str, dict] = {} + score_by_ip: dict[str, int] = {} for entry in blocklist_data: ip_addr = entry.get("ipAddress") @@ -133,9 +125,6 @@ def _parse_feed(self, blocklist_data: list) -> dict[str, dict]: if not is_valid: continue - feed_by_ip[validated_ip] = { - "abuse_confidence_score": entry.get("abuseConfidenceScore"), - "country_code": entry.get("countryCode", ""), - } + score_by_ip[validated_ip] = entry.get("abuseConfidenceScore") - return feed_by_ip + return score_by_ip diff --git a/tests/test_abuseipdb.py b/tests/test_abuseipdb.py index 2f1b604e..286afe32 100644 --- a/tests/test_abuseipdb.py +++ b/tests/test_abuseipdb.py @@ -36,7 +36,6 @@ def test_enriches_matching_iocs(self, mock_settings, mock_get): { "ipAddress": self.ioc.name, "abuseConfidenceScore": 84, - "countryCode": "CN", } ], } @@ -50,14 +49,10 @@ def test_enriches_matching_iocs(self, mock_settings, mock_get): tag_keys = set(tags.values_list("key", flat=True)) self.assertIn("confidence_of_abuse", tag_keys) - self.assertIn("country_code", tag_keys) confidence_tag = tags.get(key="confidence_of_abuse") self.assertEqual(confidence_tag.value, "84%") - country_tag = tags.get(key="country_code") - self.assertEqual(country_tag.value, "CN") - @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") @patch("greedybear.cronjobs.abuseipdb_feed.settings") def test_no_tags_for_non_matching_iocs(self, mock_settings, mock_get): @@ -70,7 +65,6 @@ def test_no_tags_for_non_matching_iocs(self, mock_settings, mock_get): { "ipAddress": "203.0.113.99", "abuseConfidenceScore": 90, - "countryCode": "RU", } ], } @@ -97,7 +91,6 @@ def test_replaces_stale_tags(self, mock_settings, mock_get): { "ipAddress": self.ioc.name, "abuseConfidenceScore": 95, - "countryCode": "CN", } ], } @@ -152,12 +145,10 @@ def test_skips_invalid_ips(self, mock_settings, mock_get): { "ipAddress": "999.999.999.999", "abuseConfidenceScore": 90, - "countryCode": "RU", }, { "ipAddress": "", "abuseConfidenceScore": 80, - "countryCode": "US", }, ], } @@ -199,12 +190,10 @@ def test_enriches_multiple_iocs(self, mock_settings, mock_get): { "ipAddress": self.ioc.name, "abuseConfidenceScore": 84, - "countryCode": "CN", }, { "ipAddress": self.ioc_2.name, "abuseConfidenceScore": 92, - "countryCode": "RU", }, ], } From 5cbda723152a1e667d7d44873aa397d437c4a10d Mon Sep 17 00:00:00 2001 From: Sahitya Aryan <115429795+Sahityaaryan@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:52:40 +0530 Subject: [PATCH 045/109] Add rDNS-based mass scanner detection via behavioral heuristics. Closes #527 (#934) --- greedybear/consts.py | 17 + .../cronjobs/extraction/ioc_processor.py | 2 +- greedybear/cronjobs/repositories/tag.py | 32 ++ greedybear/cronjobs/reverse_dns.py | 189 +++++++++ greedybear/cronjobs/schedules.py | 6 + greedybear/tasks.py | 6 + tests/test_ioc_processor.py | 29 +- tests/test_reverse_dns.py | 376 ++++++++++++++++++ tests/test_tag_repository.py | 31 ++ 9 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 greedybear/cronjobs/reverse_dns.py create mode 100644 tests/test_reverse_dns.py diff --git a/greedybear/consts.py b/greedybear/consts.py index 6e9649e6..777936fd 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -33,6 +33,23 @@ ] +# Mass scanner service domains for reverse DNS filtering. +# If a PTR record ends with one of these, the IP is classified as a mass scanner. +MASS_SCANNER_DOMAINS = frozenset( + { + "shodan.io", + "censys.io", + "onyphe.net", + "binaryedge.io", + "shadowserver.org", + "internet-census.org", + "stretchoid.com", + "internet-measurement.com", + "recyber.net", + } +) + + # we used this const to implement news feature RSS_FEED_URL = "https://intelowlproject.github.io/feed.xml" CACHE_KEY_GREEDYBEAR_NEWS = "greedybear_news" diff --git a/greedybear/cronjobs/extraction/ioc_processor.py b/greedybear/cronjobs/extraction/ioc_processor.py index 33fb0430..a3a9bf3b 100644 --- a/greedybear/cronjobs/extraction/ioc_processor.py +++ b/greedybear/cronjobs/extraction/ioc_processor.py @@ -99,7 +99,7 @@ def _merge_iocs(self, existing: IOC, new: IOC) -> IOC: existing.interaction_count += new.interaction_count existing.related_urls = sorted(set(existing.related_urls + new.related_urls)) existing.destination_ports = sorted(set(existing.destination_ports + new.destination_ports)) - existing.ip_reputation = new.ip_reputation + existing.ip_reputation = existing.ip_reputation or new.ip_reputation existing.asn = new.asn existing.firehol_categories = list(new.firehol_categories) existing.login_attempts += new.login_attempts diff --git a/greedybear/cronjobs/repositories/tag.py b/greedybear/cronjobs/repositories/tag.py index 08b0abbc..bf490475 100644 --- a/greedybear/cronjobs/repositories/tag.py +++ b/greedybear/cronjobs/repositories/tag.py @@ -50,6 +50,38 @@ def replace_tags_for_source(self, source: str, tag_entries: list[dict]) -> int: self.log.info(f"Created {len(tags_to_create)} tags from source '{source}'") return len(tags_to_create) + def add_tags(self, source: str, tag_entries: list[dict]) -> int: + """ + Incrementally add tags for a source without removing existing ones. + + Unlike replace_tags_for_source, this only creates new tags and + leaves previously stored tags intact. Suitable for sources that + build up results over many runs (e.g., reverse DNS lookups). + + Args: + source: Source name (e.g., "rdns"). + tag_entries: List of dicts with keys: ioc_id, key, value. + + Returns: + Number of tags created. + """ + if not tag_entries: + return 0 + + tags_to_create = [ + Tag( + ioc_id=entry["ioc_id"], + key=entry["key"], + value=entry["value"], + source=source, + ) + for entry in tag_entries + ] + + Tag.objects.bulk_create(tags_to_create, batch_size=1000, ignore_conflicts=True) + self.log.info(f"Added {len(tags_to_create)} tags from source '{source}'") + return len(tags_to_create) + def get_tags_by_ioc(self, ioc): """ Get all tags for a specific IOC. diff --git a/greedybear/cronjobs/reverse_dns.py b/greedybear/cronjobs/reverse_dns.py new file mode 100644 index 00000000..aae6e412 --- /dev/null +++ b/greedybear/cronjobs/reverse_dns.py @@ -0,0 +1,189 @@ +import socket +from concurrent.futures import ThreadPoolExecutor, as_completed + +from django.db.models import F + +from greedybear.consts import MASS_SCANNER_DOMAINS +from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.repositories import IocRepository +from greedybear.cronjobs.repositories.tag import TagRepository +from greedybear.models import IOC, IocType + +# Number of concurrent DNS lookups. +THREAD_POOL_SIZE = 20 + +# Maximum number of candidate IPs to check per run. +MAX_CANDIDATES = 500 + +# Timeout in seconds for each DNS lookup. +DNS_TIMEOUT = 2 + +SOURCE_NAME = "rdns" + + +class ReverseDNSCron(Cronjob): + """ + Identify mass scanning services via reverse DNS lookups. + + Runs daily, selects the top candidates most likely to be mass scanners + based on behavioral heuristics (persistent, no login attempts, low + interaction-to-attack ratio), resolves their PTR records in parallel, + and marks matches against a curated list of mass scanner domains. + Only IPs with actual PTR records are tagged, so IPs without records + are rechecked on subsequent runs. + """ + + def __init__(self, tag_repo=None, ioc_repo=None): + """ + Initialize the cron job with repository dependencies. + + Args: + tag_repo: Optional TagRepository instance for testing. + ioc_repo: Optional IocRepository instance for testing. + """ + super().__init__() + self.tag_repo = tag_repo if tag_repo is not None else TagRepository() + self.ioc_repo = ioc_repo if ioc_repo is not None else IocRepository() + + def run(self) -> None: + """ + Perform reverse DNS lookups on the most probable scanner candidates. + + 1. Select top candidates using behavioral heuristics. + 2. Resolve PTR records in parallel. + 3. Store non-empty PTR results as tags. + 4. Update reputation for IPs matching mass scanner domains. + """ + candidates = self._get_candidates() + + if not candidates: + self.log.info("No IOCs to check") + return + + ip_to_id = {name: ioc_id for ioc_id, name in candidates} + + # Resolve PTR records in parallel + ptr_results = self._resolve_batch(list(ip_to_id.keys())) + + # Only tag IPs that have actual PTR records — IPs without PTR + # are left untagged so they can be rechecked on future runs. + tag_entries = [] + total_matched = 0 + + for ip, ptr in ptr_results.items(): + if not ptr: + continue + + tag_entries.append({"ioc_id": ip_to_id[ip], "key": "ptr_record", "value": ptr}) + + if self._matches_scanner_domain(ptr): + self._update_ioc(ip) + total_matched += 1 + + created_count = self.tag_repo.add_tags(SOURCE_NAME, tag_entries) + self.log.info(f"Reverse DNS check completed. Checked {len(ptr_results)} IPs, created {created_count} tags, {total_matched} matched mass scanners") + + def _get_candidates(self): + """ + Select the top IOCs most likely to be mass scanners. + + Behavioral heuristics: + - Seen on more than 2 distinct days (persistent presence) + - Zero login attempts (scanners don't try credentials) + - Low interaction-to-attack ratio (interaction_count < 2 * attack_count) + - No existing reputation classification + - Not already tagged by this source (already has PTR on file) + + Returns the top MAX_CANDIDATES ordered by persistence. + """ + return list( + IOC.objects.filter( + type=IocType.IP, + ip_reputation="", + number_of_days_seen__gt=2, + login_attempts=0, + interaction_count__lt=F("attack_count") * 2, + ) + .exclude(tags__source=SOURCE_NAME) + .order_by("-number_of_days_seen") + .values_list("id", "name") + .distinct()[:MAX_CANDIDATES] + ) + + def _resolve_batch(self, ips: list[str]) -> dict[str, str]: + """ + Resolve PTR records for a batch of IPs in parallel. + + Sets an explicit socket timeout for the duration of the batch + and restores the previous value afterwards. Each IP is resolved + independently — one failure does not affect the rest of the batch. + + Args: + ips: List of IP addresses to resolve. + + Returns: + Dict mapping IP address to PTR hostname (or empty string). + """ + results = {} + old_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(DNS_TIMEOUT) + try: + with ThreadPoolExecutor(max_workers=THREAD_POOL_SIZE) as executor: + future_to_ip = {executor.submit(self._resolve_ptr, ip): ip for ip in ips} + for future in as_completed(future_to_ip): + ip = future_to_ip[future] + try: + results[ip] = future.result() + except Exception: + self.log.exception(f"Unexpected error resolving PTR for {ip}") + results[ip] = "" + finally: + socket.setdefaulttimeout(old_timeout) + return results + + def _resolve_ptr(self, ip: str) -> str: + """ + Perform a reverse DNS lookup. + + The socket timeout is set once by _resolve_batch before threads + are spawned, so all lookups share the configured DNS_TIMEOUT. + + Args: + ip: IP address to resolve. + + Returns: + The PTR hostname, or an empty string on any failure. + """ + try: + hostname, _, _ = socket.gethostbyaddr(ip) + return hostname + except (socket.herror, socket.gaierror, TimeoutError, OSError): + return "" + + @staticmethod + def _matches_scanner_domain(hostname: str) -> bool: + """ + Check whether a PTR hostname belongs to a mass scanning service. + + Args: + hostname: The resolved PTR record. + + Returns: + True if the hostname matches a mass scanner domain. + """ + hostname_lower = hostname.lower() + for domain in MASS_SCANNER_DOMAINS: + if hostname_lower == domain or hostname_lower.endswith("." + domain): + return True + return False + + def _update_ioc(self, ip_address: str): + """ + Update the IP reputation of an existing IOC to mark it as a mass scanner. + + Args: + ip_address: IP address to update. + """ + updated = self.ioc_repo.update_ioc_reputation(ip_address, "mass scanner") + if updated: + self.log.info(f"Marked {ip_address} as mass scanner via rDNS") diff --git a/greedybear/cronjobs/schedules.py b/greedybear/cronjobs/schedules.py index 32ebd5e3..b773d0fb 100644 --- a/greedybear/cronjobs/schedules.py +++ b/greedybear/cronjobs/schedules.py @@ -71,6 +71,12 @@ def setup_schedules(): "func": "greedybear.tasks.get_tor_exit_nodes", "cron": "7 1 * * 0", }, + # 10. Reverse DNS Scanner Check: Daily at 06:07 + { + "name": "check_reverse_dns", + "func": "greedybear.tasks.check_reverse_dns", + "cron": "7 6 * * *", + }, # 11. ThreatFox Enrichment: Weekly (Sunday) at 01:07 { "name": "enrich_threatfox", diff --git a/greedybear/tasks.py b/greedybear/tasks.py index a6c68f0b..86ff8207 100644 --- a/greedybear/tasks.py +++ b/greedybear/tasks.py @@ -83,6 +83,12 @@ def get_tor_exit_nodes(): TorExitNodesCron().execute() +def check_reverse_dns(): + from greedybear.cronjobs.reverse_dns import ReverseDNSCron + + ReverseDNSCron().execute() + + def enrich_threatfox(): from greedybear.cronjobs.threatfox_feed import ThreatFoxCron diff --git a/tests/test_ioc_processor.py b/tests/test_ioc_processor.py index f595a50a..d23a4f4d 100644 --- a/tests/test_ioc_processor.py +++ b/tests/test_ioc_processor.py @@ -242,7 +242,7 @@ def test_updating(self): self.assertEqual(result.last_seen, new_time) self.assertEqual(result.first_seen, old_time) - self.assertEqual(result.ip_reputation, "new") + self.assertEqual(result.ip_reputation, "old") self.assertEqual(result.asn, 23) def test_last_seen_not_regressed(self): @@ -275,6 +275,33 @@ def test_first_seen_not_advanced(self): self.assertEqual(result.first_seen, earlier) + def test_preserves_reputation_when_new_is_empty(self): + """Existing ip_reputation must not be overwritten by an empty value.""" + existing = self._create_mock_ioc(ip_reputation="mass scanner") + new = self._create_mock_ioc(ip_reputation="") + + result = self.processor._merge_iocs(existing, new) + + self.assertEqual(result.ip_reputation, "mass scanner") + + def test_preserves_reputation_when_existing_is_set(self): + """Existing ip_reputation must not be overwritten even if new has a value.""" + existing = self._create_mock_ioc(ip_reputation="tor exit node") + new = self._create_mock_ioc(ip_reputation="mass scanner") + + result = self.processor._merge_iocs(existing, new) + + self.assertEqual(result.ip_reputation, "tor exit node") + + def test_fills_reputation_when_existing_is_empty(self): + """Empty existing ip_reputation should be filled by a non-empty new value.""" + existing = self._create_mock_ioc(ip_reputation="") + new = self._create_mock_ioc(ip_reputation="mass scanner") + + result = self.processor._merge_iocs(existing, new) + + self.assertEqual(result.ip_reputation, "mass scanner") + def test_handles_empty_urls_and_ports(self): existing = self._create_mock_ioc(related_urls=[], destination_ports=[]) new = self._create_mock_ioc(related_urls=[], destination_ports=[]) diff --git a/tests/test_reverse_dns.py b/tests/test_reverse_dns.py new file mode 100644 index 00000000..1ed8334b --- /dev/null +++ b/tests/test_reverse_dns.py @@ -0,0 +1,376 @@ +import socket +from datetime import date +from unittest.mock import Mock, patch + +from greedybear.cronjobs import reverse_dns as reverse_dns_module +from greedybear.cronjobs.reverse_dns import ReverseDNSCron +from greedybear.models import IOC, IocType, Tag + +from . import CustomTestCase + + +class TestReverseDNSCron(CustomTestCase): + """Test cases for ReverseDNSCron.run() — the main orchestrator.""" + + def setUp(self): + self.mock_tag_repo = Mock() + self.mock_ioc_repo = Mock() + self.cron = ReverseDNSCron( + tag_repo=self.mock_tag_repo, + ioc_repo=self.mock_ioc_repo, + ) + self.cron.log = Mock() + + # Create an IOC matching all behavioral heuristics: + # persistent (days_seen > 2), no logins, low interaction ratio + self.candidate_ioc = IOC.objects.create( + name="10.20.30.40", + type=IocType.IP.value, + first_seen=self.current_time, + last_seen=self.current_time, + days_seen=[date(2025, 1, 1), date(2025, 1, 2), date(2025, 1, 3)], + number_of_days_seen=3, + attack_count=10, + interaction_count=5, + ip_reputation="", + login_attempts=0, + destination_ports=[], + related_urls=[], + ) + + def tearDown(self): + IOC.objects.filter(name="10.20.30.40").delete() + Tag.objects.filter(source="rdns").delete() + + def _mock_resolve(self, ptr_value): + """Return a side_effect for _resolve_batch that maps every IP to ptr_value.""" + return lambda ips: dict.fromkeys(ips, ptr_value) + + def test_eligible_ioc_is_resolved(self): + """An IOC matching behavioral heuristics should be resolved.""" + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + mock_resolve.assert_called_once() + resolved_ips = mock_resolve.call_args[0][0] + self.assertIn(self.candidate_ioc.name, resolved_ips) + + def test_skips_already_tagged_ips(self): + """IPs already tagged by rdns source should not be queried again.""" + Tag.objects.create(ioc=self.candidate_ioc, key="ptr_record", value="scanner.shodan.io", source="rdns") + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + mock_resolve.assert_not_called() + + def test_skips_ips_with_existing_reputation(self): + """IPs that already have a reputation should not be checked.""" + IOC.objects.filter(name=self.candidate_ioc.name).update(ip_reputation="mass scanner") + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + mock_resolve.assert_not_called() + + def test_skips_domain_type_iocs(self): + """Domain-type IOCs should not be checked (only IPs).""" + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + resolved_ips = mock_resolve.call_args[0][0] + self.assertNotIn("malicious.example.com", resolved_ips) + + def test_skips_ips_with_few_days_seen(self): + """IPs seen on 2 or fewer days should not be candidates.""" + IOC.objects.filter(name=self.candidate_ioc.name).update(number_of_days_seen=2) + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + mock_resolve.assert_not_called() + + def test_skips_ips_with_login_attempts(self): + """IPs with login attempts are not mass scanners and should be skipped.""" + IOC.objects.filter(name=self.candidate_ioc.name).update(login_attempts=1) + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + mock_resolve.assert_not_called() + + def test_skips_ips_with_high_interaction_ratio(self): + """IPs with interaction_count >= 2 * attack_count should be skipped.""" + IOC.objects.filter(name=self.candidate_ioc.name).update(attack_count=10, interaction_count=20) + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + mock_resolve.assert_not_called() + + def test_stores_ptr_as_tag(self): + """Resolved PTR records should be stored as tags via add_tags.""" + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("scanner.shodan.io")): + self.cron.run() + + self.mock_tag_repo.add_tags.assert_called_once() + source, tag_entries = self.mock_tag_repo.add_tags.call_args[0] + self.assertEqual(source, "rdns") + self.assertEqual(len(tag_entries), 1) + self.assertEqual(tag_entries[0]["key"], "ptr_record") + self.assertEqual(tag_entries[0]["value"], "scanner.shodan.io") + self.assertEqual(tag_entries[0]["ioc_id"], self.candidate_ioc.id) + + def test_does_not_store_empty_ptr(self): + """IPs with no PTR record should NOT be tagged, allowing rechecks.""" + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")): + self.cron.run() + + self.mock_tag_repo.add_tags.assert_called_once() + tag_entries = self.mock_tag_repo.add_tags.call_args[0][1] + self.assertEqual(len(tag_entries), 0) + + def test_updates_reputation_on_scanner_match(self): + """Matching PTR should trigger a reputation update to 'mass scanner'.""" + self.mock_ioc_repo.update_ioc_reputation.return_value = True + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("probe.censys.io")): + self.cron.run() + + self.mock_ioc_repo.update_ioc_reputation.assert_called_with(self.candidate_ioc.name, "mass scanner") + + def test_no_reputation_update_on_non_scanner_ptr(self): + """Non-scanner PTR records should not cause reputation updates.""" + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("mail.google.com")): + self.cron.run() + + self.mock_ioc_repo.update_ioc_reputation.assert_not_called() + + def test_no_reputation_update_on_empty_ptr(self): + """Empty PTR results should not cause reputation updates.""" + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")): + self.cron.run() + + self.mock_ioc_repo.update_ioc_reputation.assert_not_called() + + def test_candidates_ordered_by_persistence(self): + """Most persistent IPs should be checked first.""" + more_persistent = IOC.objects.create( + name="10.20.30.41", + type=IocType.IP.value, + first_seen=self.current_time, + last_seen=self.current_time, + days_seen=[date(2025, 1, i) for i in range(1, 11)], + number_of_days_seen=10, + attack_count=100, + interaction_count=50, + ip_reputation="", + login_attempts=0, + destination_ports=[], + related_urls=[], + ) + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + resolved_ips = mock_resolve.call_args[0][0] + idx_persistent = resolved_ips.index(more_persistent.name) + idx_candidate = resolved_ips.index(self.candidate_ioc.name) + self.assertLess(idx_persistent, idx_candidate) + + more_persistent.delete() + + def test_max_candidates_limit(self): + """No more than MAX_CANDIDATES should be checked per run.""" + max_candidates = 3 + extra_iocs = [] + for i in range(max_candidates + 2): + ioc = IOC.objects.create( + name=f"10.0.{i}.1", + type=IocType.IP.value, + first_seen=self.current_time, + last_seen=self.current_time, + days_seen=[date(2025, 1, 1), date(2025, 1, 2), date(2025, 1, 3)], + number_of_days_seen=3, + attack_count=10, + interaction_count=5, + ip_reputation="", + login_attempts=0, + destination_ports=[], + related_urls=[], + ) + extra_iocs.append(ioc) + + with ( + patch.object(reverse_dns_module, "MAX_CANDIDATES", max_candidates), + patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve, + ): + self.cron.run() + + resolved_ips = mock_resolve.call_args[0][0] + self.assertLessEqual(len(resolved_ips), max_candidates) + + for ioc in extra_iocs: + ioc.delete() + + def test_fixture_iocs_excluded_by_behavioral_filters(self): + """Base fixture IOCs should not match (they have login_attempts=1, days_seen=1).""" + # Delete the candidate so only fixtures remain + self.candidate_ioc.delete() + + with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: + self.cron.run() + + mock_resolve.assert_not_called() + + +class TestReverseDNSCronResolveBatch(CustomTestCase): + """Tests for _resolve_batch — parallel PTR resolution.""" + + def setUp(self): + self.cron = ReverseDNSCron(tag_repo=Mock(), ioc_repo=Mock()) + + @patch("greedybear.cronjobs.reverse_dns.socket.gethostbyaddr") + def test_resolve_batch_returns_results_for_all_ips(self, mock_gethostbyaddr): + mock_gethostbyaddr.side_effect = lambda ip: (f"host-{ip}.example.com", [], [ip]) + + results = self.cron._resolve_batch(["1.2.3.4", "5.6.7.8"]) + + self.assertEqual(results["1.2.3.4"], "host-1.2.3.4.example.com") + self.assertEqual(results["5.6.7.8"], "host-5.6.7.8.example.com") + + @patch("greedybear.cronjobs.reverse_dns.socket.gethostbyaddr") + def test_resolve_batch_handles_mixed_results(self, mock_gethostbyaddr): + def side_effect(ip): + if ip == "1.2.3.4": + return ("scanner.shodan.io", [], [ip]) + raise socket.herror("Host not found") + + mock_gethostbyaddr.side_effect = side_effect + + results = self.cron._resolve_batch(["1.2.3.4", "5.6.7.8"]) + + self.assertEqual(results["1.2.3.4"], "scanner.shodan.io") + self.assertEqual(results["5.6.7.8"], "") + + def test_resolve_batch_handles_unexpected_exception(self): + """An unexpected exception in one IP should not crash the batch.""" + self.cron.log = Mock() + + def bad_resolve(ip): + if ip == "1.2.3.4": + raise RuntimeError("unexpected") + return "host.example.com" + + with patch.object(self.cron, "_resolve_ptr", side_effect=bad_resolve): + results = self.cron._resolve_batch(["1.2.3.4", "5.6.7.8"]) + + self.assertEqual(results["1.2.3.4"], "") + self.assertEqual(results["5.6.7.8"], "host.example.com") + + +class TestReverseDNSCronResolvePTR(CustomTestCase): + """Tests for _resolve_ptr method.""" + + def setUp(self): + self.cron = ReverseDNSCron(tag_repo=Mock(), ioc_repo=Mock()) + + @patch("greedybear.cronjobs.reverse_dns.socket.gethostbyaddr") + def test_resolve_ptr_success(self, mock_gethostbyaddr): + mock_gethostbyaddr.return_value = ("scanner.shodan.io", [], ["1.2.3.4"]) + + result = self.cron._resolve_ptr("1.2.3.4") + + self.assertEqual(result, "scanner.shodan.io") + mock_gethostbyaddr.assert_called_once_with("1.2.3.4") + + @patch("greedybear.cronjobs.reverse_dns.socket.gethostbyaddr") + def test_resolve_ptr_herror(self, mock_gethostbyaddr): + mock_gethostbyaddr.side_effect = socket.herror("Host not found") + + result = self.cron._resolve_ptr("1.2.3.4") + + self.assertEqual(result, "") + + @patch("greedybear.cronjobs.reverse_dns.socket.gethostbyaddr") + def test_resolve_ptr_timeout(self, mock_gethostbyaddr): + mock_gethostbyaddr.side_effect = TimeoutError("timed out") + + result = self.cron._resolve_ptr("1.2.3.4") + + self.assertEqual(result, "") + + @patch("greedybear.cronjobs.reverse_dns.socket.gethostbyaddr") + def test_resolve_ptr_gaierror(self, mock_gethostbyaddr): + mock_gethostbyaddr.side_effect = socket.gaierror("Name resolution failed") + + result = self.cron._resolve_ptr("1.2.3.4") + + self.assertEqual(result, "") + + @patch("greedybear.cronjobs.reverse_dns.socket.gethostbyaddr") + def test_resolve_ptr_oserror(self, mock_gethostbyaddr): + mock_gethostbyaddr.side_effect = OSError("Network unreachable") + + result = self.cron._resolve_ptr("1.2.3.4") + + self.assertEqual(result, "") + + +class TestReverseDNSCronMatchesScannerDomain(CustomTestCase): + """Tests for _matches_scanner_domain static method.""" + + def test_exact_domain_match(self): + self.assertTrue(ReverseDNSCron._matches_scanner_domain("shodan.io")) + + def test_subdomain_match(self): + self.assertTrue(ReverseDNSCron._matches_scanner_domain("scanner.shodan.io")) + + def test_deep_subdomain_match(self): + self.assertTrue(ReverseDNSCron._matches_scanner_domain("a.b.censys.io")) + + def test_case_insensitive_match(self): + self.assertTrue(ReverseDNSCron._matches_scanner_domain("Scanner.SHODAN.IO")) + + def test_non_scanner_domain(self): + self.assertFalse(ReverseDNSCron._matches_scanner_domain("mail.google.com")) + + def test_partial_name_no_match(self): + """A domain ending with a scanner name but not as a subdomain should not match.""" + self.assertFalse(ReverseDNSCron._matches_scanner_domain("notshodan.io")) + + def test_empty_hostname(self): + self.assertFalse(ReverseDNSCron._matches_scanner_domain("")) + + def test_all_scanner_domains(self): + """Every domain in MASS_SCANNER_DOMAINS should match.""" + from greedybear.consts import MASS_SCANNER_DOMAINS + + for domain in MASS_SCANNER_DOMAINS: + self.assertTrue(ReverseDNSCron._matches_scanner_domain(domain), f"{domain} should match") + self.assertTrue(ReverseDNSCron._matches_scanner_domain(f"probe.{domain}"), f"probe.{domain} should match") + + +class TestReverseDNSCronUpdateIoc(CustomTestCase): + """Tests for _update_ioc method.""" + + def setUp(self): + self.mock_ioc_repo = Mock() + self.cron = ReverseDNSCron(tag_repo=Mock(), ioc_repo=self.mock_ioc_repo) + self.cron.log = Mock() + + def test_update_ioc_success(self): + self.mock_ioc_repo.update_ioc_reputation.return_value = True + + self.cron._update_ioc("1.2.3.4") + + self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("1.2.3.4", "mass scanner") + self.cron.log.info.assert_called_once() + + def test_update_ioc_not_found(self): + self.mock_ioc_repo.update_ioc_reputation.return_value = False + + self.cron._update_ioc("9.9.9.9") + + self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("9.9.9.9", "mass scanner") + self.cron.log.info.assert_not_called() diff --git a/tests/test_tag_repository.py b/tests/test_tag_repository.py index 0a33a22f..8ba05e52 100644 --- a/tests/test_tag_repository.py +++ b/tests/test_tag_repository.py @@ -85,6 +85,37 @@ def test_delete_tags_by_source(self): self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 1) + def test_add_tags_creates_tags(self): + """Should create new tags without removing existing ones.""" + tag_entries = [ + {"ioc_id": self.ioc.id, "key": "ptr_record", "value": "scanner.shodan.io"}, + {"ioc_id": self.ioc_2.id, "key": "ptr_record", "value": "probe.censys.io"}, + ] + + count = self.repo.add_tags("rdns", tag_entries) + + self.assertEqual(count, 2) + self.assertEqual(Tag.objects.filter(source="rdns").count(), 2) + + def test_add_tags_preserves_existing_tags(self): + """Should not delete existing tags from the same source.""" + Tag.objects.create(ioc=self.ioc, key="ptr_record", value="old.example.com", source="rdns") + + tag_entries = [ + {"ioc_id": self.ioc_2.id, "key": "ptr_record", "value": "new.example.com"}, + ] + + self.repo.add_tags("rdns", tag_entries) + + self.assertEqual(Tag.objects.filter(source="rdns").count(), 2) + + def test_add_tags_with_empty_list_returns_zero(self): + """Should return 0 and create nothing when given an empty list.""" + count = self.repo.add_tags("rdns", []) + + self.assertEqual(count, 0) + self.assertEqual(Tag.objects.filter(source="rdns").count(), 0) + def test_tags_deleted_when_ioc_deleted(self): """Tags should be cascade deleted when their IOC is deleted.""" Tag.objects.create(ioc=self.ioc, key="malware", value="Mirai", source="threatfox") From 6c7dc2b3d1e839b91dd94cbfb4d69680a1dc39c2 Mon Sep 17 00:00:00 2001 From: Varun chauhan <115783538+chauhan-varun@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:55:16 +0530 Subject: [PATCH 046/109] Frontend: Improve Error Boundary Coverage. Closes #978 (#982) * feat: add an ErrorBoundary component with tests and integrate it into protected application routes. * chore: add pull request template and reformat ErrorBoundary component and tests. * refactor: standardize error boundary prop to `withErrorBoundary` and expand coverage to more routes. --- frontend/src/components/Routes.jsx | 38 +++++++++++- frontend/src/wrappers/ErrorBoundary.jsx | 58 +++++++++++++++++++ .../tests/wrappers/ErrorBoundary.test.jsx | 58 +++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 frontend/src/wrappers/ErrorBoundary.jsx create mode 100644 frontend/tests/wrappers/ErrorBoundary.test.jsx diff --git a/frontend/src/components/Routes.jsx b/frontend/src/components/Routes.jsx index fcff3933..15140fe7 100644 --- a/frontend/src/components/Routes.jsx +++ b/frontend/src/components/Routes.jsx @@ -3,6 +3,7 @@ import { FallBackLoading } from "@certego/certego-ui"; import IfAuthRedirectGuard from "../wrappers/ifAuthRedirectGuard"; import AuthGuard from "../wrappers/AuthGuard"; +import ErrorBoundary from "../wrappers/ErrorBoundary"; const Home = React.lazy(() => import("./home/Home")); const Login = React.lazy(() => import("./auth/Login")); @@ -23,20 +24,30 @@ const publicRoutesLazy = [ { index: true, element: , + withErrorBoundary: true, }, /* Dashboard */ { path: "/dashboard", element: , + withErrorBoundary: true, }, /* Feeds */ { path: "/feeds", element: , + withErrorBoundary: true, }, ].map((r) => ({ ...r, - element: }>{r.element}, + element: ( + {children}} + > + }>{r.element} + + ), })); // no auth public components @@ -44,24 +55,33 @@ const noAuthRoutesLazy = [ { path: "/login", element: , + withErrorBoundary: true, }, { path: "/register", element: , + withErrorBoundary: true, }, { path: "/verify-email", element: , + withErrorBoundary: true, }, { path: "/reset-password", element: , + withErrorBoundary: true, }, ].map((r) => ({ ...r, element: ( - }>{r.element} + {children}} + > + }>{r.element} + ), })); @@ -72,24 +92,36 @@ const authRoutesLazy = [ { path: "/logout", element: , + withErrorBoundary: true, }, /* API Access/Sessions Management */ { path: "/me/sessions", element: , + withErrorBoundary: true, }, /* Change Password */ { path: "/me/change-password", element: , + withErrorBoundary: true, }, ].map((r) => ({ ...r, element: ( - }>{r.element} + {children}} + > + }>{r.element} + ), })); +function ConditionalWrapper({ condition, wrapper, children }) { + return condition ? wrapper(children) : children; +} + export { publicRoutesLazy, noAuthRoutesLazy, authRoutesLazy }; diff --git a/frontend/src/wrappers/ErrorBoundary.jsx b/frontend/src/wrappers/ErrorBoundary.jsx new file mode 100644 index 00000000..b8f587ce --- /dev/null +++ b/frontend/src/wrappers/ErrorBoundary.jsx @@ -0,0 +1,58 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Container, Button } from "reactstrap"; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + // You can also log the error to an error reporting service + console.error("ErrorBoundary caught an error", error, errorInfo); + } + + render() { + if (this.state.hasError) { + // You can render any custom fallback UI + return ( + +

Something went wrong.

+

+ An unexpected error occurred in this section of the application. +

+
+ + +
+
+ ); + } + + return this.props.children; + } +} + +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default ErrorBoundary; diff --git a/frontend/tests/wrappers/ErrorBoundary.test.jsx b/frontend/tests/wrappers/ErrorBoundary.test.jsx new file mode 100644 index 00000000..7667ed9b --- /dev/null +++ b/frontend/tests/wrappers/ErrorBoundary.test.jsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import ErrorBoundary from "../../src/wrappers/ErrorBoundary"; + +// A component that throws an error +const ThrowError = ({ shouldThrow }) => { + if (shouldThrow) { + throw new Error("Test error"); + } + return
Component rendered successfully
; +}; + +describe("ErrorBoundary", () => { + // Prevent console.error from cluttering the test output + const originalError = console.error; + beforeAll(() => { + console.error = vi.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + + it("renders children when there is no error", () => { + render( + + + , + ); + expect( + screen.getByText("Component rendered successfully"), + ).toBeInTheDocument(); + }); + + it("renders fallback UI when there is an error", () => { + render( + + + , + ); + expect(screen.getByText("Something went wrong.")).toBeInTheDocument(); + expect( + screen.getByText( + "An unexpected error occurred in this section of the application.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Reload Page")).toBeInTheDocument(); + expect(screen.getByText("Go to Home")).toBeInTheDocument(); + }); + + it("calls console.error when an error is caught", () => { + render( + + + , + ); + expect(console.error).toHaveBeenCalled(); + }); +}); From eceac534b80f509256de233bed23b0a93a17fc92 Mon Sep 17 00:00:00 2001 From: Deepanshu <144600350+Deepanshu1230@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:59:10 +0530 Subject: [PATCH 047/109] test: add tests for AuthGuard and IfAuthRedirectGuard. Closes #972 (#988) * test: add tests for AuthGuard and IfAuthRedirectGuard * added the Linting * fix: fixing some issues --- frontend/tests/wrapper/AuthGuard.test.jsx | 131 ++++++++++++++++++ .../wrapper/IfAuthRedirectGuard.test.jsx | 99 +++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 frontend/tests/wrapper/AuthGuard.test.jsx create mode 100644 frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx diff --git a/frontend/tests/wrapper/AuthGuard.test.jsx b/frontend/tests/wrapper/AuthGuard.test.jsx new file mode 100644 index 00000000..dd5828af --- /dev/null +++ b/frontend/tests/wrapper/AuthGuard.test.jsx @@ -0,0 +1,131 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import AuthGuard from "../../src/wrappers/AuthGuard"; +import { AUTHENTICATION_STATUSES } from "../../src/constants"; +import { addToast } from "@certego/certego-ui"; + +const mockUseAuthStore = vi.fn(); +vi.mock("../../src/stores", () => ({ + useAuthStore: (selector) => mockUseAuthStore(selector), +})); + +vi.mock("@certego/certego-ui", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + addToast: vi.fn(), + FallBackLoading: () =>
Loading...
, + }; +}); + +describe("AuthGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("shows fallback loading when authentication status is pending", () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.PENDING }), + ); + + render( + + + +
Protected Content
+ + } + /> +
+
, + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.queryByText("Protected Content")).not.toBeInTheDocument(); + }); + + test("renders children when user is authenticated", () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }), + ); + + render( + + + +
Protected Content
+ + } + /> +
+
, + ); + + expect(screen.getByText("Protected Content")).toBeInTheDocument(); + }); + + test("redirects unauthenticated user to /login with ?next param", () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.FALSE }), + ); + + render( + + + +
Protected Content
+ + } + /> + Login Page} /> +
+
, + ); + + expect(screen.getByText("Login Page")).toBeInTheDocument(); + expect(screen.queryByText("Protected Content")).not.toBeInTheDocument(); + expect(addToast).toHaveBeenCalledWith( + "Login required to access the requested page.", + null, + "info", + ); + }); + + test("redirects to / when user visits /logout while unauthenticated", () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.FALSE }), + ); + + render( + + + +
Protected Content
+ + } + /> + Home Page} /> + Login Page} /> +
+
, + ); + + expect(screen.getByText("Home Page")).toBeInTheDocument(); + expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); + expect(addToast).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx b/frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx new file mode 100644 index 00000000..05e22dec --- /dev/null +++ b/frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx @@ -0,0 +1,99 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { AUTHENTICATION_STATUSES } from "../../src/constants"; +import IfAuthRedirectGuard from "../../src/wrappers/ifAuthRedirectGuard"; + +const mockUseAuthStore = vi.fn(); +vi.mock("../../src/stores", () => ({ + useAuthStore: (selector) => mockUseAuthStore(selector), +})); + +const mockUseSearchParam = vi.fn(); +vi.mock("react-use/lib/useSearchParam", () => ({ + default: () => mockUseSearchParam(), +})); + +describe("IfAuthRedirectGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseSearchParam.mockReturnValue(null); + }); + + test("renders children when user is not authenticated", () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.FALSE }), + ); + + render( + + + +
Login Page
+ + } + /> +
+
, + ); + + expect(screen.getByText("Login Page")).toBeInTheDocument(); + }); + + test("redirects authenticated user from /login to / when no next param", () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }), + ); + mockUseSearchParam.mockReturnValue(null); + + render( + + + +
Login Page
+ + } + /> + Home Page} /> +
+
, + ); + + expect(screen.getByText("Home Page")).toBeInTheDocument(); + expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); + }); + + test("redirects authenticated user to ?next param when provided", () => { + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }), + ); + mockUseSearchParam.mockReturnValue("/dashboard"); + + render( + + + +
Login Page
+ + } + /> + Dashboard Page} /> + Home Page} /> +
+
, + ); + + expect(screen.getByText("Dashboard Page")).toBeInTheDocument(); + expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); + }); +}); From a6f5ddf277d23731a23d017a9b90260f744651f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:36:48 +0100 Subject: [PATCH 048/109] build(deps): bump stix2 from 3.0.1 to 3.0.2 in /requirements (#1015) Bumps [stix2](https://github.com/oasis-open/cti-python-stix2) from 3.0.1 to 3.0.2. - [Release notes](https://github.com/oasis-open/cti-python-stix2/releases) - [Changelog](https://github.com/oasis-open/cti-python-stix2/blob/master/CHANGELOG) - [Commits](https://github.com/oasis-open/cti-python-stix2/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: stix2 dependency-version: 3.0.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> --- requirements/project-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt index b77ba2e5..3371bc93 100644 --- a/requirements/project-requirements.txt +++ b/requirements/project-requirements.txt @@ -24,4 +24,4 @@ numpy==2.4.2 datasketch==1.9.0 feedparser==6.0.12 -stix2==3.0.1 \ No newline at end of file +stix2==3.0.2 \ No newline at end of file From ab2e0d1c1a60555103b4fcbea42a7317e2a2d3c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:37:01 +0100 Subject: [PATCH 049/109] build(deps): bump numpy from 2.4.2 to 2.4.3 in /requirements (#1016) Bumps [numpy](https://github.com/numpy/numpy) from 2.4.2 to 2.4.3. - [Release notes](https://github.com/numpy/numpy/releases) - [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst) - [Commits](https://github.com/numpy/numpy/compare/v2.4.2...v2.4.3) --- updated-dependencies: - dependency-name: numpy dependency-version: 2.4.3 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> --- requirements/project-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt index 3371bc93..852aac3d 100644 --- a/requirements/project-requirements.txt +++ b/requirements/project-requirements.txt @@ -20,7 +20,7 @@ pyuwsgi==2.0.30 joblib==1.5.3 pandas==3.0.1 scikit-learn==1.8.0 -numpy==2.4.2 +numpy==2.4.3 datasketch==1.9.0 feedparser==6.0.12 From 66408414243261597e20ba4548e76ce856be6416 Mon Sep 17 00:00:00 2001 From: armoredvortex <66690593+armoredvortex@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:44:04 +0530 Subject: [PATCH 050/109] Attack Origin Visualizer for Dashboard. Closes #955 (#983) * add endpoint to retrieve top attacker countries by IOC count * add attack origin Map and top countries chart to dashboard * overrride d3-color version to ^3.1.0 due to vulnerable 2.x.x version * mock chart in dashboard for now. * fix inverted coloring on charts * handle maxcount in map legend; prevent tooltip re-render on every mouse move; * filter inactive honeypots in countries statistics query * add test for countries statistics * add tests for AttackOriginCountriesChart component * add tests for AttackOriginMap component * vendor the topojson in the frontend bundle * distinct count of attacker countries in statistics query * filter inactive general honeypots in attacker country statistics * update WORLD_ATLAS_GEO_URL to use BASE_URL * add license for topojson * exhaustive name_fixes list * update attacker country codes to full names in statistics tests * fix color_empty verification test * fix map zoom so that full map is visible default * modify tests to use data in test/__init__.py --- api/views/statistics.py | 23 ++ frontend/package-lock.json | 274 +++++++++++++++--- frontend/package.json | 4 + frontend/public/countries-110m.json | 1 + frontend/public/countries-110m.json.LICENSE | 13 + .../components/dashboard/AttackOriginMap.jsx | 267 +++++++++++++++++ .../src/components/dashboard/Dashboard.jsx | 28 ++ .../src/components/dashboard/utils/charts.jsx | 111 ++++++- frontend/src/constants/api.js | 1 + .../AttackOriginCountriesChart.test.jsx | 115 ++++++++ .../dashboard/AttackOriginMap.test.jsx | 133 +++++++++ .../components/dashboard/Dashboard.test.jsx | 6 + .../EnrichmentLookup.integration.test.jsx | 6 + tests/__init__.py | 18 ++ tests/api/views/test_statistics_view.py | 18 ++ 15 files changed, 976 insertions(+), 42 deletions(-) create mode 100644 frontend/public/countries-110m.json create mode 100644 frontend/public/countries-110m.json.LICENSE create mode 100644 frontend/src/components/dashboard/AttackOriginMap.jsx create mode 100644 frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx create mode 100644 frontend/tests/components/dashboard/AttackOriginMap.test.jsx diff --git a/api/views/statistics.py b/api/views/statistics.py index 347dfa1a..adea971b 100644 --- a/api/views/statistics.py +++ b/api/views/statistics.py @@ -77,6 +77,29 @@ def enrichment(self, request, pk=None): return HttpResponseServerError() return self.__aggregation_response_static_statistics(annotations) + @action(detail=False, methods=["get"]) + def countries(self, request): + """ + Retrieve the top attacker countries by IOC count for the selected time range. + + Args: + request: The incoming request object. + + Returns: + Response: A JSON list of {country, count} objects ordered by count descending. + """ + delta, _ = self.__parse_range(self.request) + qs = ( + IOC.objects.filter(last_seen__gte=delta) + .exclude(attacker_country="") + .filter(general_honeypot__active=True) + .values("attacker_country") + .annotate(count=Count("id", distinct=True)) + .order_by("-count") + ) + data = [{"country": item["attacker_country"], "count": item["count"]} for item in qs] + return Response(data) + @action(detail=False, methods=["get"]) def feeds_types(self, request): """ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce3dbd4d..b34e77fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "react-dom": "^17.0.2", "react-icons": "^4.12.0", "react-router-dom": "^6.30.3", + "react-simple-maps": "^3.0.0", "react-table": "^7.8.0", "react-use": "^17.6.0", "reactstrap": "^9.2.3", @@ -1956,9 +1957,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1973,9 +1971,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1990,9 +1985,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2007,9 +1999,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2024,9 +2013,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2041,9 +2027,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2058,9 +2041,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2075,9 +2055,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2092,9 +2069,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2109,9 +2083,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2126,9 +2097,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2143,9 +2111,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2160,9 +2125,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3347,6 +3309,12 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3519,6 +3487,22 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-drag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", + "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-selection": "2" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -3535,6 +3519,15 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", + "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^2.5.0" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -3569,6 +3562,12 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", + "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==", + "license": "BSD-3-Clause" + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -3610,6 +3609,65 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", + "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-color": "1 - 2", + "d3-dispatch": "1 - 2", + "d3-ease": "1 - 2", + "d3-interpolate": "1 - 2", + "d3-timer": "1 - 2" + }, + "peerDependencies": { + "d3-selection": "2" + } + }, + "node_modules/d3-transition/node_modules/d3-ease": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", + "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-transition/node_modules/d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-color": "1 - 2" + } + }, + "node_modules/d3-transition/node_modules/d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-zoom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", + "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-drag": "2", + "d3-interpolate": "1 - 2", + "d3-selection": "2", + "d3-transition": "2" + } + }, + "node_modules/d3-zoom/node_modules/d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-color": "1 - 2" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7273,6 +7331,23 @@ "react": "^16.3.0 || ^17" } }, + "node_modules/react-simple-maps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", + "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==", + "license": "MIT", + "dependencies": { + "d3-geo": "^2.0.2", + "d3-selection": "^2.0.0", + "d3-zoom": "^2.0.0", + "topojson-client": "^3.1.0" + }, + "peerDependencies": { + "prop-types": "^15.7.2", + "react": "^16.8.0 || 17.x || 18.x", + "react-dom": "^16.8.0 || 17.x || 18.x" + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -8710,6 +8785,20 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -11509,6 +11598,11 @@ "delayed-stream": "~1.0.0" } }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -11646,6 +11740,20 @@ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" }, + "d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==" + }, + "d3-drag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", + "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", + "requires": { + "d3-dispatch": "1 - 2", + "d3-selection": "2" + } + }, "d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -11656,12 +11764,20 @@ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==" }, + "d3-geo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", + "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", + "requires": { + "d3-array": "^2.5.0" + } + }, "d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "requires": { - "d3-color": "1 - 3" + "d3-color": "^3.1.0" } }, "d3-path": { @@ -11681,6 +11797,11 @@ "d3-time-format": "2 - 4" } }, + "d3-selection": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", + "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==" + }, "d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -11710,6 +11831,60 @@ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" }, + "d3-transition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", + "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", + "requires": { + "d3-color": "^3.1.0", + "d3-dispatch": "1 - 2", + "d3-ease": "1 - 2", + "d3-interpolate": "1 - 2", + "d3-timer": "1 - 2" + }, + "dependencies": { + "d3-ease": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", + "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==" + }, + "d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "requires": { + "d3-color": "^3.1.0" + } + }, + "d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==" + } + } + }, + "d3-zoom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", + "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==", + "requires": { + "d3-dispatch": "1 - 2", + "d3-drag": "2", + "d3-interpolate": "1 - 2", + "d3-selection": "2", + "d3-transition": "2" + }, + "dependencies": { + "d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "requires": { + "d3-color": "^3.1.0" + } + } + } + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -14315,6 +14490,17 @@ "jsonp": "^0.2.1" } }, + "react-simple-maps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", + "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==", + "requires": { + "d3-geo": "^2.0.2", + "d3-selection": "^2.0.0", + "d3-zoom": "^2.0.0", + "topojson-client": "^3.1.0" + } + }, "react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -15315,6 +15501,14 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "requires": { + "commander": "2" + } + }, "tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d0acdcc6..42f2e6bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "react-dom": "^17.0.2", "react-icons": "^4.12.0", "react-router-dom": "^6.30.3", + "react-simple-maps": "^3.0.0", "react-table": "^7.8.0", "react-use": "^17.6.0", "reactstrap": "^9.2.3", @@ -41,6 +42,9 @@ "formatter": "prettier 'src/**/*.{js,jsx}' 'tests/**/*.{js,jsx}' 'src/styles/*.{css,scss}' --check", "formatter-fix": "npm run formatter -- --write" }, + "overrides": { + "d3-color": "^3.1.0" + }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^12.1.5", diff --git a/frontend/public/countries-110m.json b/frontend/public/countries-110m.json new file mode 100644 index 00000000..055d19ff --- /dev/null +++ b/frontend/public/countries-110m.json @@ -0,0 +1 @@ +{"type":"Topology","objects":{"countries":{"type":"GeometryCollection","geometries":[{"type":"MultiPolygon","arcs":[[[0]],[[1]]],"id":"242","properties":{"name":"Fiji"}},{"type":"Polygon","arcs":[[2,3,4,5,6,7,8,9,10]],"id":"834","properties":{"name":"Tanzania"}},{"type":"Polygon","arcs":[[11,12,13,14]],"id":"732","properties":{"name":"W. Sahara"}},{"type":"MultiPolygon","arcs":[[[15,16,17,18]],[[19]],[[20]],[[21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]],[[28]],[[29]],[[30]],[[31]],[[32]],[[33]],[[34]],[[35]],[[36]],[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]]],"id":"124","properties":{"name":"Canada"}},{"type":"MultiPolygon","arcs":[[[-19,48,49,50]],[[51]],[[52]],[[53]],[[54]],[[55]],[[56]],[[57]],[[-17,58]],[[59]]],"id":"840","properties":{"name":"United States of America"}},{"type":"Polygon","arcs":[[60,61,62,63,64,65]],"id":"398","properties":{"name":"Kazakhstan"}},{"type":"Polygon","arcs":[[-63,66,67,68,69]],"id":"860","properties":{"name":"Uzbekistan"}},{"type":"MultiPolygon","arcs":[[[70,71]],[[72]],[[73]],[[74]]],"id":"598","properties":{"name":"Papua New Guinea"}},{"type":"MultiPolygon","arcs":[[[-72,75]],[[76,77]],[[78]],[[79,80]],[[81]],[[82]],[[83]],[[84]],[[85]],[[86]],[[87]],[[88]],[[89]]],"id":"360","properties":{"name":"Indonesia"}},{"type":"MultiPolygon","arcs":[[[90,91]],[[92,93,94,95,96,97]]],"id":"032","properties":{"name":"Argentina"}},{"type":"MultiPolygon","arcs":[[[-92,98]],[[99,-95,100,101]]],"id":"152","properties":{"name":"Chile"}},{"type":"Polygon","arcs":[[-8,102,103,104,105,106,107,108,109,110,111]],"id":"180","properties":{"name":"Dem. Rep. Congo"}},{"type":"Polygon","arcs":[[112,113,114,115]],"id":"706","properties":{"name":"Somalia"}},{"type":"Polygon","arcs":[[-3,116,117,118,-113,119]],"id":"404","properties":{"name":"Kenya"}},{"type":"Polygon","arcs":[[120,121,122,123,124,125,126,127]],"id":"729","properties":{"name":"Sudan"}},{"type":"Polygon","arcs":[[-122,128,129,130,131]],"id":"148","properties":{"name":"Chad"}},{"type":"Polygon","arcs":[[132,133]],"id":"332","properties":{"name":"Haiti"}},{"type":"Polygon","arcs":[[-133,134]],"id":"214","properties":{"name":"Dominican Rep."}},{"type":"MultiPolygon","arcs":[[[135]],[[136]],[[137]],[[138]],[[139]],[[140]],[[141,142,143]],[[144]],[[145]],[[146,147,148,149,-66,150,151,152,153,154,155,156,157,158,159,160,161]],[[162]],[[163,164]]],"id":"643","properties":{"name":"Russia"}},{"type":"MultiPolygon","arcs":[[[165]],[[166]],[[167]]],"id":"044","properties":{"name":"Bahamas"}},{"type":"Polygon","arcs":[[168]],"id":"238","properties":{"name":"Falkland Is."}},{"type":"MultiPolygon","arcs":[[[169]],[[-161,170,171,172]],[[173]],[[174]]],"id":"578","properties":{"name":"Norway"}},{"type":"Polygon","arcs":[[175]],"id":"304","properties":{"name":"Greenland"}},{"type":"Polygon","arcs":[[176]],"id":"260","properties":{"name":"Fr. S. Antarctic Lands"}},{"type":"Polygon","arcs":[[177,-77]],"id":"626","properties":{"name":"Timor-Leste"}},{"type":"Polygon","arcs":[[178,179,180,181,182,183,184],[185]],"id":"710","properties":{"name":"South Africa"}},{"type":"Polygon","arcs":[[-186]],"id":"426","properties":{"name":"Lesotho"}},{"type":"Polygon","arcs":[[-50,186,187,188,189]],"id":"484","properties":{"name":"Mexico"}},{"type":"Polygon","arcs":[[190,191,-93]],"id":"858","properties":{"name":"Uruguay"}},{"type":"Polygon","arcs":[[-191,-98,192,193,194,195,196,197,198,199,200]],"id":"076","properties":{"name":"Brazil"}},{"type":"Polygon","arcs":[[-194,201,-96,-100,202]],"id":"068","properties":{"name":"Bolivia"}},{"type":"Polygon","arcs":[[-195,-203,-102,203,204,205]],"id":"604","properties":{"name":"Peru"}},{"type":"Polygon","arcs":[[-196,-206,206,207,208,209,210]],"id":"170","properties":{"name":"Colombia"}},{"type":"Polygon","arcs":[[-209,211,212,213]],"id":"591","properties":{"name":"Panama"}},{"type":"Polygon","arcs":[[-213,214,215,216]],"id":"188","properties":{"name":"Costa Rica"}},{"type":"Polygon","arcs":[[-216,217,218,219]],"id":"558","properties":{"name":"Nicaragua"}},{"type":"Polygon","arcs":[[-219,220,221,222,223]],"id":"340","properties":{"name":"Honduras"}},{"type":"Polygon","arcs":[[-222,224,225]],"id":"222","properties":{"name":"El Salvador"}},{"type":"Polygon","arcs":[[-189,226,227,-223,-226,228]],"id":"320","properties":{"name":"Guatemala"}},{"type":"Polygon","arcs":[[-188,229,-227]],"id":"084","properties":{"name":"Belize"}},{"type":"Polygon","arcs":[[-197,-211,230,231]],"id":"862","properties":{"name":"Venezuela"}},{"type":"Polygon","arcs":[[-198,-232,232,233]],"id":"328","properties":{"name":"Guyana"}},{"type":"Polygon","arcs":[[-199,-234,234,235]],"id":"740","properties":{"name":"Suriname"}},{"type":"MultiPolygon","arcs":[[[-200,-236,236]],[[237,238,239,240,241,242,243,244]],[[245]]],"id":"250","properties":{"name":"France"}},{"type":"Polygon","arcs":[[-205,246,-207]],"id":"218","properties":{"name":"Ecuador"}},{"type":"Polygon","arcs":[[247]],"id":"630","properties":{"name":"Puerto Rico"}},{"type":"Polygon","arcs":[[248]],"id":"388","properties":{"name":"Jamaica"}},{"type":"Polygon","arcs":[[249]],"id":"192","properties":{"name":"Cuba"}},{"type":"Polygon","arcs":[[-181,250,251,252]],"id":"716","properties":{"name":"Zimbabwe"}},{"type":"Polygon","arcs":[[-180,253,254,-251]],"id":"072","properties":{"name":"Botswana"}},{"type":"Polygon","arcs":[[-179,255,256,257,-254]],"id":"516","properties":{"name":"Namibia"}},{"type":"Polygon","arcs":[[258,259,260,261,262,263,264]],"id":"686","properties":{"name":"Senegal"}},{"type":"Polygon","arcs":[[-261,265,266,267,268,269,270]],"id":"466","properties":{"name":"Mali"}},{"type":"Polygon","arcs":[[-13,271,-266,-260,272]],"id":"478","properties":{"name":"Mauritania"}},{"type":"Polygon","arcs":[[273,274,275,276,277]],"id":"204","properties":{"name":"Benin"}},{"type":"Polygon","arcs":[[-131,278,279,-277,280,-268,281,282]],"id":"562","properties":{"name":"Niger"}},{"type":"Polygon","arcs":[[-278,-280,283,284]],"id":"566","properties":{"name":"Nigeria"}},{"type":"Polygon","arcs":[[-130,285,286,287,288,289,-284,-279]],"id":"120","properties":{"name":"Cameroon"}},{"type":"Polygon","arcs":[[-275,290,291,292]],"id":"768","properties":{"name":"Togo"}},{"type":"Polygon","arcs":[[-292,293,294,295]],"id":"288","properties":{"name":"Ghana"}},{"type":"Polygon","arcs":[[-270,296,-295,297,298,299]],"id":"384","properties":{"name":"Côte d'Ivoire"}},{"type":"Polygon","arcs":[[-262,-271,-300,300,301,302,303]],"id":"324","properties":{"name":"Guinea"}},{"type":"Polygon","arcs":[[-263,-304,304]],"id":"624","properties":{"name":"Guinea-Bissau"}},{"type":"Polygon","arcs":[[-299,305,306,-301]],"id":"430","properties":{"name":"Liberia"}},{"type":"Polygon","arcs":[[-302,-307,307]],"id":"694","properties":{"name":"Sierra Leone"}},{"type":"Polygon","arcs":[[-269,-281,-276,-293,-296,-297]],"id":"854","properties":{"name":"Burkina Faso"}},{"type":"Polygon","arcs":[[-108,308,-286,-129,-121,309]],"id":"140","properties":{"name":"Central African Rep."}},{"type":"Polygon","arcs":[[-107,310,311,312,-287,-309]],"id":"178","properties":{"name":"Congo"}},{"type":"Polygon","arcs":[[-288,-313,313,314]],"id":"266","properties":{"name":"Gabon"}},{"type":"Polygon","arcs":[[-289,-315,315]],"id":"226","properties":{"name":"Eq. Guinea"}},{"type":"Polygon","arcs":[[-7,316,317,-252,-255,-258,318,-103]],"id":"894","properties":{"name":"Zambia"}},{"type":"Polygon","arcs":[[-6,319,-317]],"id":"454","properties":{"name":"Malawi"}},{"type":"Polygon","arcs":[[-5,320,-184,321,-182,-253,-318,-320]],"id":"508","properties":{"name":"Mozambique"}},{"type":"Polygon","arcs":[[-183,-322]],"id":"748","properties":{"name":"eSwatini"}},{"type":"MultiPolygon","arcs":[[[-106,322,-311]],[[-104,-319,-257,323]]],"id":"024","properties":{"name":"Angola"}},{"type":"Polygon","arcs":[[-9,-112,324]],"id":"108","properties":{"name":"Burundi"}},{"type":"Polygon","arcs":[[325,326,327,328,329,330,331]],"id":"376","properties":{"name":"Israel"}},{"type":"Polygon","arcs":[[-331,332,333]],"id":"422","properties":{"name":"Lebanon"}},{"type":"Polygon","arcs":[[334]],"id":"450","properties":{"name":"Madagascar"}},{"type":"Polygon","arcs":[[-327,335]],"id":"275","properties":{"name":"Palestine"}},{"type":"Polygon","arcs":[[-265,336]],"id":"270","properties":{"name":"Gambia"}},{"type":"Polygon","arcs":[[337,338,339]],"id":"788","properties":{"name":"Tunisia"}},{"type":"Polygon","arcs":[[-12,340,341,-338,342,-282,-267,-272]],"id":"012","properties":{"name":"Algeria"}},{"type":"Polygon","arcs":[[-326,343,344,345,346,-328,-336]],"id":"400","properties":{"name":"Jordan"}},{"type":"Polygon","arcs":[[347,348,349,350,351]],"id":"784","properties":{"name":"United Arab Emirates"}},{"type":"Polygon","arcs":[[352,353]],"id":"634","properties":{"name":"Qatar"}},{"type":"Polygon","arcs":[[354,355,356]],"id":"414","properties":{"name":"Kuwait"}},{"type":"Polygon","arcs":[[-345,357,358,359,360,-357,361]],"id":"368","properties":{"name":"Iraq"}},{"type":"MultiPolygon","arcs":[[[-351,362,363,364]],[[-349,365]]],"id":"512","properties":{"name":"Oman"}},{"type":"MultiPolygon","arcs":[[[366]],[[367]]],"id":"548","properties":{"name":"Vanuatu"}},{"type":"Polygon","arcs":[[368,369,370,371]],"id":"116","properties":{"name":"Cambodia"}},{"type":"Polygon","arcs":[[-369,372,373,374,375,376]],"id":"764","properties":{"name":"Thailand"}},{"type":"Polygon","arcs":[[-370,-377,377,378,379]],"id":"418","properties":{"name":"Laos"}},{"type":"Polygon","arcs":[[-376,380,381,382,383,-378]],"id":"104","properties":{"name":"Myanmar"}},{"type":"Polygon","arcs":[[-371,-380,384,385]],"id":"704","properties":{"name":"Vietnam"}},{"type":"MultiPolygon","arcs":[[[386,386,386]],[[-147,387,388,389,390]]],"id":"408","properties":{"name":"North Korea"}},{"type":"Polygon","arcs":[[-389,391]],"id":"410","properties":{"name":"South Korea"}},{"type":"Polygon","arcs":[[-149,392]],"id":"496","properties":{"name":"Mongolia"}},{"type":"Polygon","arcs":[[-383,393,394,395,396,397,398,399,400]],"id":"356","properties":{"name":"India"}},{"type":"Polygon","arcs":[[-382,401,-394]],"id":"050","properties":{"name":"Bangladesh"}},{"type":"Polygon","arcs":[[-400,402]],"id":"064","properties":{"name":"Bhutan"}},{"type":"Polygon","arcs":[[-398,403]],"id":"524","properties":{"name":"Nepal"}},{"type":"Polygon","arcs":[[-396,404,405,406,407]],"id":"586","properties":{"name":"Pakistan"}},{"type":"Polygon","arcs":[[-69,408,409,-407,410,411]],"id":"004","properties":{"name":"Afghanistan"}},{"type":"Polygon","arcs":[[-68,412,413,-409]],"id":"762","properties":{"name":"Tajikistan"}},{"type":"Polygon","arcs":[[-62,414,-413,-67]],"id":"417","properties":{"name":"Kyrgyzstan"}},{"type":"Polygon","arcs":[[-64,-70,-412,415,416]],"id":"795","properties":{"name":"Turkmenistan"}},{"type":"Polygon","arcs":[[-360,417,418,419,420,421,-416,-411,-406,422]],"id":"364","properties":{"name":"Iran"}},{"type":"Polygon","arcs":[[-332,-334,423,424,-358,-344]],"id":"760","properties":{"name":"Syria"}},{"type":"Polygon","arcs":[[-420,425,426,427,428]],"id":"051","properties":{"name":"Armenia"}},{"type":"Polygon","arcs":[[-172,429,430]],"id":"752","properties":{"name":"Sweden"}},{"type":"Polygon","arcs":[[-156,431,432,433,434]],"id":"112","properties":{"name":"Belarus"}},{"type":"Polygon","arcs":[[-155,435,-164,436,437,438,439,440,441,442,-432]],"id":"804","properties":{"name":"Ukraine"}},{"type":"Polygon","arcs":[[-433,-443,443,444,445,446,-142,447]],"id":"616","properties":{"name":"Poland"}},{"type":"Polygon","arcs":[[448,449,450,451,452,453,454]],"id":"040","properties":{"name":"Austria"}},{"type":"Polygon","arcs":[[-441,455,456,457,458,-449,459]],"id":"348","properties":{"name":"Hungary"}},{"type":"Polygon","arcs":[[-439,460]],"id":"498","properties":{"name":"Moldova"}},{"type":"Polygon","arcs":[[-438,461,462,463,-456,-440,-461]],"id":"642","properties":{"name":"Romania"}},{"type":"Polygon","arcs":[[-434,-448,-144,464,465]],"id":"440","properties":{"name":"Lithuania"}},{"type":"Polygon","arcs":[[-157,-435,-466,466,467]],"id":"428","properties":{"name":"Latvia"}},{"type":"Polygon","arcs":[[-158,-468,468]],"id":"233","properties":{"name":"Estonia"}},{"type":"Polygon","arcs":[[-446,469,-453,470,-238,471,472,473,474,475,476]],"id":"276","properties":{"name":"Germany"}},{"type":"Polygon","arcs":[[-463,477,478,479,480,481]],"id":"100","properties":{"name":"Bulgaria"}},{"type":"MultiPolygon","arcs":[[[482]],[[-480,483,484,485,486]]],"id":"300","properties":{"name":"Greece"}},{"type":"MultiPolygon","arcs":[[[-359,-425,487,488,-427,-418]],[[-479,489,-484]]],"id":"792","properties":{"name":"Turkey"}},{"type":"Polygon","arcs":[[-486,490,491,492,493]],"id":"008","properties":{"name":"Albania"}},{"type":"Polygon","arcs":[[-458,494,495,496,497,498]],"id":"191","properties":{"name":"Croatia"}},{"type":"Polygon","arcs":[[-452,499,-239,-471]],"id":"756","properties":{"name":"Switzerland"}},{"type":"Polygon","arcs":[[-472,-245,500]],"id":"442","properties":{"name":"Luxembourg"}},{"type":"Polygon","arcs":[[-473,-501,-244,501,502]],"id":"056","properties":{"name":"Belgium"}},{"type":"Polygon","arcs":[[-474,-503,503]],"id":"528","properties":{"name":"Netherlands"}},{"type":"Polygon","arcs":[[504,505]],"id":"620","properties":{"name":"Portugal"}},{"type":"Polygon","arcs":[[-505,506,-242,507]],"id":"724","properties":{"name":"Spain"}},{"type":"Polygon","arcs":[[508,509]],"id":"372","properties":{"name":"Ireland"}},{"type":"Polygon","arcs":[[510]],"id":"540","properties":{"name":"New Caledonia"}},{"type":"MultiPolygon","arcs":[[[511]],[[512]],[[513]],[[514]],[[515]]],"id":"090","properties":{"name":"Solomon Is."}},{"type":"MultiPolygon","arcs":[[[516]],[[517]]],"id":"554","properties":{"name":"New Zealand"}},{"type":"MultiPolygon","arcs":[[[518]],[[519]]],"id":"036","properties":{"name":"Australia"}},{"type":"Polygon","arcs":[[520]],"id":"144","properties":{"name":"Sri Lanka"}},{"type":"MultiPolygon","arcs":[[[521]],[[-61,-150,-393,-148,-391,522,-385,-379,-384,-401,-403,-399,-404,-397,-408,-410,-414,-415]]],"id":"156","properties":{"name":"China"}},{"type":"Polygon","arcs":[[523]],"id":"158","properties":{"name":"Taiwan"}},{"type":"MultiPolygon","arcs":[[[-451,524,525,-240,-500]],[[526]],[[527]]],"id":"380","properties":{"name":"Italy"}},{"type":"MultiPolygon","arcs":[[[-476,528]],[[529]]],"id":"208","properties":{"name":"Denmark"}},{"type":"MultiPolygon","arcs":[[[-510,530]],[[531]]],"id":"826","properties":{"name":"United Kingdom"}},{"type":"Polygon","arcs":[[532]],"id":"352","properties":{"name":"Iceland"}},{"type":"MultiPolygon","arcs":[[[-152,533,-421,-429,534]],[[-419,-426]]],"id":"031","properties":{"name":"Azerbaijan"}},{"type":"Polygon","arcs":[[-153,-535,-428,-489,535]],"id":"268","properties":{"name":"Georgia"}},{"type":"MultiPolygon","arcs":[[[536]],[[537]],[[538]],[[539]],[[540]],[[541]],[[542]]],"id":"608","properties":{"name":"Philippines"}},{"type":"MultiPolygon","arcs":[[[-374,543]],[[-81,544,545,546]]],"id":"458","properties":{"name":"Malaysia"}},{"type":"Polygon","arcs":[[-546,547]],"id":"096","properties":{"name":"Brunei"}},{"type":"Polygon","arcs":[[-450,-459,-499,548,-525]],"id":"705","properties":{"name":"Slovenia"}},{"type":"Polygon","arcs":[[-160,549,-430,-171]],"id":"246","properties":{"name":"Finland"}},{"type":"Polygon","arcs":[[-442,-460,-455,550,-444]],"id":"703","properties":{"name":"Slovakia"}},{"type":"Polygon","arcs":[[-445,-551,-454,-470]],"id":"203","properties":{"name":"Czechia"}},{"type":"Polygon","arcs":[[-126,551,552,553]],"id":"232","properties":{"name":"Eritrea"}},{"type":"MultiPolygon","arcs":[[[554]],[[555]],[[556]]],"id":"392","properties":{"name":"Japan"}},{"type":"Polygon","arcs":[[-193,-97,-202]],"id":"600","properties":{"name":"Paraguay"}},{"type":"Polygon","arcs":[[-364,557,558]],"id":"887","properties":{"name":"Yemen"}},{"type":"Polygon","arcs":[[-346,-362,-356,559,-354,560,-352,-365,-559,561]],"id":"682","properties":{"name":"Saudi Arabia"}},{"type":"MultiPolygon","arcs":[[[562]],[[563]],[[564]],[[565]],[[566]],[[567]],[[568]],[[569]]],"id":"010","properties":{"name":"Antarctica"}},{"type":"Polygon","arcs":[[570,571]],"properties":{"name":"N. Cyprus"}},{"type":"Polygon","arcs":[[-572,572]],"id":"196","properties":{"name":"Cyprus"}},{"type":"Polygon","arcs":[[-341,-15,573]],"id":"504","properties":{"name":"Morocco"}},{"type":"Polygon","arcs":[[-124,574,575,-329,576]],"id":"818","properties":{"name":"Egypt"}},{"type":"Polygon","arcs":[[-123,-132,-283,-343,-340,577,-575]],"id":"434","properties":{"name":"Libya"}},{"type":"Polygon","arcs":[[-114,-119,578,-127,-554,579,580]],"id":"231","properties":{"name":"Ethiopia"}},{"type":"Polygon","arcs":[[-553,581,582,-580]],"id":"262","properties":{"name":"Djibouti"}},{"type":"Polygon","arcs":[[-115,-581,-583,583]],"properties":{"name":"Somaliland"}},{"type":"Polygon","arcs":[[-11,584,-110,585,-117]],"id":"800","properties":{"name":"Uganda"}},{"type":"Polygon","arcs":[[-10,-325,-111,-585]],"id":"646","properties":{"name":"Rwanda"}},{"type":"Polygon","arcs":[[-496,586,587]],"id":"070","properties":{"name":"Bosnia and Herz."}},{"type":"Polygon","arcs":[[-481,-487,-494,588,589]],"id":"807","properties":{"name":"Macedonia"}},{"type":"Polygon","arcs":[[-457,-464,-482,-590,590,591,-587,-495]],"id":"688","properties":{"name":"Serbia"}},{"type":"Polygon","arcs":[[-492,592,-497,-588,-592,593]],"id":"499","properties":{"name":"Montenegro"}},{"type":"Polygon","arcs":[[-493,-594,-591,-589]],"properties":{"name":"Kosovo"}},{"type":"Polygon","arcs":[[594]],"id":"780","properties":{"name":"Trinidad and Tobago"}},{"type":"Polygon","arcs":[[-109,-310,-128,-579,-118,-586]],"id":"728","properties":{"name":"S. Sudan"}}]},"land":{"type":"GeometryCollection","geometries":[{"type":"MultiPolygon","arcs":[[[0]],[[1]],[[3,320,184,255,323,104,322,311,313,315,289,284,273,290,293,297,305,307,302,304,263,336,258,272,13,573,341,338,577,575,329,332,423,487,535,153,435,164,436,461,477,489,484,490,592,497,548,525,240,507,505,506,242,501,503,474,528,476,446,142,464,466,468,158,549,430,172,161,387,391,389,522,385,371,372,543,374,380,401,394,404,422,360,354,559,352,560,347,365,349,362,557,561,346,576,124,551,581,583,115,119],[421,416,64,150,533]],[[17,48,186,229,227,223,219,216,213,209,230,232,234,236,200,191,93,100,203,246,207,211,214,217,220,224,228,189,50,15,58]],[[19]],[[20]],[[21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]],[[28]],[[29]],[[30]],[[31]],[[32]],[[33]],[[34]],[[35]],[[36]],[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]],[[51]],[[52]],[[53]],[[54]],[[55]],[[56]],[[57]],[[59]],[[70,75]],[[72]],[[73]],[[74]],[[77,177]],[[78]],[[546,79,544,547]],[[81]],[[82]],[[83]],[[84]],[[85]],[[86]],[[87]],[[88]],[[89]],[[90,98]],[[133,134]],[[135]],[[136]],[[137]],[[138]],[[139]],[[140]],[[144]],[[145]],[[162]],[[165]],[[166]],[[167]],[[168]],[[169]],[[173]],[[174]],[[175]],[[176]],[[245]],[[247]],[[248]],[[249]],[[334]],[[366]],[[367]],[[482]],[[508,530]],[[510]],[[511]],[[512]],[[513]],[[514]],[[515]],[[516]],[[517]],[[518]],[[519]],[[520]],[[521]],[[523]],[[526]],[[527]],[[529]],[[531]],[[532]],[[536]],[[537]],[[538]],[[539]],[[540]],[[541]],[[542]],[[554]],[[555]],[[556]],[[562]],[[563]],[[564]],[[565]],[[566]],[[567]],[[568]],[[569]],[[570,572]],[[594]]]}]}},"arcs":[[[99478,40237],[69,98],[96,-171],[-46,-308],[-172,-81],[-153,73],[-27,260],[107,203],[126,-74]],[[0,41087],[57,27],[-34,-284],[-23,-32],[99822,-145],[-177,-124],[-36,220],[139,121],[88,33],[163,184],[-99999,0]],[[59417,50018],[47,-65],[1007,-1203],[19,-343],[399,-590]],[[60889,47817],[-128,-728],[16,-335],[178,-216],[8,-153],[-76,-357],[16,-180],[-18,-282],[97,-370],[115,-583],[101,-129]],[[61198,44484],[-221,-342],[-303,-230],[-167,10],[-99,-177],[-193,-16],[-73,-74],[-334,166],[-209,-48]],[[59599,43773],[-77,804],[-95,275],[-55,164],[-273,110]],[[59099,45126],[-157,177],[-177,100],[-111,99],[-116,150]],[[58538,45652],[-150,745],[-161,330],[-55,343],[27,307],[-50,544]],[[58149,47921],[115,28],[101,214],[108,308],[69,124],[-3,192],[-60,134],[-16,233]],[[58463,49154],[80,74],[16,348],[-110,333]],[[58449,49909],[98,71],[304,-7],[566,45]],[[47592,66920],[1,-40],[-6,-114]],[[47587,66766],[-1,-895],[-911,31],[9,-1512],[-261,-53],[-68,-304],[53,-853],[-1088,4],[-60,-197]],[[45260,62987],[12,249]],[[45272,63236],[5,-1],[625,48],[33,213],[114,265],[92,816],[386,637],[131,745],[86,44],[91,460],[234,63],[100,-76],[126,0],[90,134],[172,19],[-7,317],[42,0]],[[15878,79530],[-38,1],[-537,581],[-199,255],[-503,244],[-155,523],[40,363],[-356,252],[-48,476],[-336,429],[-6,304]],[[13740,82958],[154,285],[-7,373],[-473,376],[-284,674],[-173,424],[-255,266],[-187,242],[-147,306],[-279,-192],[-270,-330],[-247,388],[-194,259],[-271,164],[-273,17],[1,3364],[2,2193]],[[10837,91767],[518,-142],[438,-285],[289,-54],[244,247],[336,184],[413,-72],[416,259],[455,148],[191,-245],[207,138],[62,278],[192,-63],[470,-530],[369,401],[38,-449],[341,97],[105,173],[337,-34],[424,-248],[650,-217],[383,-100],[272,38],[374,-300],[-390,-293],[502,-127],[750,70],[236,103],[296,-354],[302,299],[-283,251],[179,202],[338,27],[223,59],[224,-141],[279,-321],[310,47],[491,-266],[431,94],[405,-14],[-32,367],[247,103],[431,-200],[-2,-559],[177,471],[223,-16],[126,594],[-298,364],[-324,239],[22,653],[329,429],[366,-95],[281,-261],[378,-666],[-247,-290],[517,-120],[-1,-604],[371,463],[332,-380],[-83,-438],[269,-399],[290,427],[202,510],[16,649],[394,-46],[411,-87],[373,-293],[17,-293],[-207,-315],[196,-316],[-36,-288],[-544,-413],[-386,-91],[-287,178],[-83,-297],[-268,-498],[-81,-259],[-322,-399],[-397,-39],[-220,-250],[-18,-384],[-323,-74],[-340,-479],[-301,-665],[-108,-466],[-16,-686],[409,-99],[125,-553],[130,-448],[388,117],[517,-256],[277,-225],[199,-279],[348,-163],[294,-248],[459,-34],[302,-58],[-45,-511],[86,-594],[201,-661],[414,-561],[214,192],[150,607],[-145,934],[-196,311],[445,276],[314,415],[154,411],[-23,395],[-188,502],[-338,445],[328,619],[-121,535],[-93,922],[194,137],[476,-161],[286,-57],[230,155],[258,-200],[342,-343],[85,-229],[495,-45],[-8,-496],[92,-747],[254,-92],[201,-348],[402,328],[266,652],[184,274],[216,-527],[362,-754],[307,-709],[-112,-371],[370,-333],[250,-338],[442,-152],[179,-189],[110,-500],[216,-78],[112,-223],[20,-664],[-202,-222],[-199,-207],[-458,-210],[-349,-486],[-470,-96],[-594,125],[-417,4],[-287,-41],[-233,-424],[-354,-262],[-401,-782],[-320,-545],[236,97],[446,776],[583,493],[415,58],[246,-289],[-262,-397],[88,-637],[91,-446],[361,-295],[459,86],[278,664],[19,-429],[180,-214],[-344,-387],[-615,-351],[-276,-239],[-310,-426],[-211,44],[-11,500],[483,488],[-445,-19],[-309,-72]],[[31350,77248],[-181,334],[0,805],[-123,171],[-187,-100],[-92,155],[-212,-446],[-84,-460],[-99,-269],[-118,-91],[-89,-30],[-28,-146],[-512,0],[-422,-4],[-125,-109],[-294,-425],[-34,-46],[-89,-231],[-255,1],[-273,-3],[-125,-93],[44,-116],[25,-181],[-5,-60],[-363,-293],[-286,-93],[-323,-316],[-70,0],[-94,93],[-31,85],[6,61],[61,207],[131,325],[81,349],[-56,514],[-59,536],[-290,277],[35,105],[-41,73],[-76,0],[-56,93],[-14,140],[-54,-61],[-75,18],[17,59],[-65,58],[-27,155],[-216,189],[-224,197],[-272,229],[-261,214],[-248,-167],[-91,-6],[-342,154],[-225,-77],[-269,183],[-284,94],[-194,36],[-86,100],[-49,325],[-94,-3],[-1,-227],[-575,0],[-951,0],[-944,0],[-833,0],[-834,0],[-819,0],[-847,0],[-273,0],[-824,0],[-789,0]],[[26668,87478],[207,273],[381,-6],[-6,-114],[-325,-326],[-196,13],[-61,160]],[[27840,93593],[-306,313],[12,213],[133,39],[636,-63],[479,-325],[25,-163],[-296,17],[-299,13],[-304,-80],[-80,36]],[[27690,87261],[107,177],[114,-13],[70,-121],[-108,-310],[-123,50],[-73,176],[13,41]],[[23996,94879],[-151,-229],[-403,44],[-337,155],[148,266],[399,159],[243,-208],[101,-187]],[[23933,96380],[-126,-17],[-521,38],[-74,165],[559,-9],[195,-109],[-33,-68]],[[23124,97116],[332,-205],[-76,-214],[-411,-122],[-226,138],[-119,221],[-22,245],[360,-24],[162,-39]],[[25514,94532],[-449,73],[-738,190],[-96,325],[-34,293],[-279,258],[-574,72],[-322,183],[104,242],[573,-37],[308,-190],[547,1],[240,-194],[-64,-222],[319,-134],[177,-140],[374,-26],[406,-50],[441,128],[566,51],[451,-42],[298,-223],[62,-244],[-174,-157],[-414,-127],[-355,72],[-797,-91],[-570,-11]],[[19093,96754],[392,-92],[-93,-177],[-518,-170],[-411,191],[224,188],[406,60]],[[19177,97139],[361,-120],[-339,-115],[-461,1],[5,84],[285,177],[149,-27]],[[34555,80899],[-148,-372],[-184,-517],[181,199],[187,-126],[-98,-206],[247,-162],[128,144],[277,-182],[-86,-433],[194,101],[36,-313],[86,-367],[-117,-520],[-125,-22],[-183,111],[60,484],[-77,75],[-322,-513],[-166,21],[196,277],[-267,144],[-298,-35],[-539,18],[-43,175],[173,208],[-121,160],[234,356],[287,941],[172,336],[241,204],[129,-26],[-54,-160]],[[26699,89048],[304,-203],[318,-184],[25,-281],[204,46],[199,-196],[-247,-186],[-432,142],[-156,266],[-275,-314],[-396,-306],[-95,346],[-377,-57],[242,292],[35,465],[95,542],[201,-49],[51,-259],[143,91],[161,-155]],[[28119,93327],[263,235],[616,-299],[383,-282],[36,-258],[515,134],[290,-376],[670,-234],[242,-238],[263,-553],[-510,-275],[654,-386],[441,-130],[400,-543],[437,-39],[-87,-414],[-487,-687],[-342,253],[-437,568],[-359,-74],[-35,-338],[292,-344],[377,-272],[114,-157],[181,-584],[-96,-425],[-350,160],[-697,473],[393,-509],[289,-357],[45,-206],[-753,236],[-596,343],[-337,287],[97,167],[-414,304],[-405,286],[5,-171],[-803,-94],[-235,203],[183,435],[522,10],[571,76],[-92,211],[96,294],[360,576],[-77,261],[-107,203],[-425,286],[-563,201],[178,150],[-294,367],[-245,34],[-219,201],[-149,-175],[-503,-76],[-1011,132],[-588,174],[-450,89],[-231,207],[290,270],[-394,2],[-88,599],[213,528],[286,241],[717,158],[-204,-382],[219,-369],[256,477],[704,242],[477,-611],[-42,-387],[550,172]],[[23749,94380],[579,-20],[530,-144],[-415,-526],[-331,-115],[-298,-442],[-317,22],[-173,519],[4,294],[145,251],[276,161]],[[15873,95551],[472,442],[570,383],[426,-9],[381,87],[-38,-454],[-214,-205],[-259,-29],[-517,-252],[-444,-91],[-377,128]],[[13136,82508],[267,47],[-84,-671],[242,-475],[-111,1],[-167,270],[-103,272],[-140,184],[-51,260],[16,188],[131,-76]],[[20696,97433],[546,-81],[751,-215],[212,-281],[108,-247],[-453,66],[-457,192],[-619,21],[268,176],[-335,142],[-21,227]],[[15692,79240],[-140,-82],[-456,269],[-84,209],[-248,207],[-50,168],[-286,107],[-107,321],[24,137],[291,-129],[171,-89],[261,-63],[94,-204],[138,-280],[277,-244],[115,-327]],[[16239,94566],[397,-123],[709,-33],[270,-171],[298,-249],[-349,-149],[-681,-415],[-344,-414],[0,-257],[-731,-285],[-147,259],[-641,312],[119,250],[192,432],[241,388],[-272,362],[939,93]],[[20050,95391],[247,99],[291,-26],[49,-289],[-169,-281],[-940,-91],[-701,-256],[-423,-14],[-35,193],[577,261],[-1255,-70],[-389,106],[379,577],[262,165],[782,-199],[493,-350],[485,-45],[-397,565],[255,215],[286,-68],[94,-282],[109,-210]],[[20410,93755],[311,-239],[175,-575],[86,-417],[466,-293],[502,-279],[-31,-260],[-456,-48],[178,-227],[-94,-217],[-503,93],[-478,160],[-322,-36],[-522,-201],[-704,-88],[-494,-56],[-151,279],[-379,161],[-246,-66],[-343,468],[185,62],[429,101],[392,-26],[362,103],[-537,138],[-594,-47],[-394,12],[-146,217],[644,237],[-428,-9],[-485,156],[233,443],[193,235],[744,359],[284,-114],[-139,-277],[618,179],[386,-298],[314,302],[254,-194],[227,-580],[140,244],[-197,606],[244,86],[276,-94]],[[22100,93536],[-306,386],[329,286],[331,-124],[496,75],[72,-172],[-259,-283],[420,-254],[-50,-532],[-455,-229],[-268,50],[-192,225],[-690,456],[5,189],[567,-73]],[[20389,94064],[372,24],[211,-130],[-244,-390],[-434,413],[95,83]],[[22639,95907],[212,-273],[9,-303],[-127,-440],[-458,-60],[-298,94],[5,345],[-455,-46],[-18,457],[299,-18],[419,201],[390,-34],[22,77]],[[23329,98201],[192,180],[285,42],[-122,135],[646,30],[355,-315],[468,-127],[455,-112],[220,-390],[334,-190],[-381,-176],[-513,-445],[-492,-42],[-575,76],[-299,240],[4,215],[220,157],[-508,-4],[-306,196],[-176,268],[193,262]],[[24559,98965],[413,112],[324,19],[545,96],[409,220],[344,-30],[300,-166],[211,319],[367,95],[498,65],[849,24],[148,-63],[802,100],[601,-38],[602,-37],[742,-47],[597,-75],[508,-161],[-12,-157],[-678,-257],[-672,-119],[-251,-133],[605,3],[-656,-358],[-452,-167],[-476,-483],[-573,-98],[-177,-120],[-841,-64],[383,-74],[-192,-105],[230,-292],[-264,-202],[-429,-167],[-132,-232],[-388,-176],[39,-134],[475,23],[6,-144],[-742,-355],[-726,163],[-816,-91],[-414,71],[-525,31],[-35,284],[514,133],[-137,427],[170,41],[742,-255],[-379,379],[-450,113],[225,229],[492,141],[79,206],[-392,231],[-118,304],[759,-26],[220,-64],[433,216],[-625,68],[-972,-38],[-491,201],[-232,239],[-324,173],[-61,202]],[[29106,90427],[-180,-174],[-312,-30],[-69,289],[118,331],[255,82],[217,-163],[3,-253],[-32,-82]],[[23262,91636],[169,-226],[-173,-207],[-374,179],[-226,-65],[-380,266],[245,183],[194,256],[295,-168],[166,-106],[84,-112]],[[32078,80046],[96,49],[365,-148],[284,-247],[8,-108],[-135,-11],[-360,186],[-258,279]],[[32218,78370],[97,-288],[202,-79],[257,16],[-137,-242],[-102,-38],[-353,250],[-69,198],[105,183]],[[31350,77248],[48,-194],[-296,-286],[-286,-204],[-293,-175],[-147,-351],[-47,-133],[-3,-313],[92,-313],[115,-15],[-29,216],[83,-131],[-22,-169],[-188,-96],[-133,11],[-205,-103],[-121,-29],[-162,-29],[-231,-171],[408,111],[82,-112],[-389,-177],[-177,-1],[8,72],[-84,-164],[82,-27],[-60,-424],[-203,-455],[-20,152],[-61,30],[-91,148],[57,-318],[69,-105],[5,-223],[-89,-230],[-157,-472],[-25,24],[86,402],[-142,225],[-33,491],[-53,-255],[59,-375],[-183,93],[191,-191],[12,-562],[79,-41],[29,-204],[39,-591],[-176,-439],[-288,-175],[-182,-346],[-139,-38],[-141,-217],[-39,-199],[-305,-383],[-157,-281],[-131,-351],[-43,-419],[50,-411],[92,-505],[124,-418],[1,-256],[132,-685],[-9,-398],[-12,-230],[-69,-361],[-83,-75],[-137,72],[-44,259],[-105,136],[-148,508],[-129,452],[-42,231],[57,393],[-77,325],[-217,494],[-108,90],[-281,-268],[-49,30],[-135,275],[-174,147],[-314,-75],[-247,66],[-212,-41],[-114,-92],[50,-157],[-5,-240],[59,-117],[-53,-77],[-103,87],[-104,-112],[-202,18],[-207,312],[-242,-73],[-202,137],[-173,-42],[-234,-138],[-253,-438],[-276,-255],[-152,-282],[-63,-266],[-3,-407],[14,-284],[52,-201]],[[23016,65864],[-108,-18],[-197,130],[-217,184],[-78,277],[-61,414],[-164,337],[-96,346],[-139,404],[-196,236],[-227,-11],[-175,-467],[-230,177],[-144,178],[-69,325],[-92,309],[-165,260],[-142,186],[-102,210],[-481,0],[0,-244],[-221,0],[-552,-4],[-634,416],[-419,287],[26,116],[-353,-64],[-316,-46]],[[17464,69802],[-46,302],[-180,340],[-130,71],[-30,169],[-156,30],[-100,159],[-258,59],[-71,95],[-33,324],[-270,594],[-231,821],[10,137],[-123,195],[-215,495],[-38,482],[-148,323],[61,489],[-10,507],[-89,453],[109,557],[34,536],[33,536],[-50,792],[-88,506],[-80,274],[33,115],[402,-200],[148,-558],[69,156],[-45,484],[-94,485]],[[6833,62443],[49,-51],[45,-79],[71,-207],[-7,-33],[-108,-126],[-89,-92],[-41,-99],[-69,84],[8,165],[-46,216],[14,65],[48,97],[-19,116],[16,55],[21,-11],[107,-100]],[[6668,62848],[-23,-71],[-94,-43],[-47,125],[-32,48],[-3,37],[27,50],[99,-56],[73,-90]],[[6456,63091],[-9,-63],[-149,17],[21,72],[137,-26]],[[6104,63411],[23,-38],[80,-196],[-15,-34],[-19,8],[-97,21],[-35,133],[-11,24],[74,82]],[[5732,63705],[5,-138],[-33,-58],[-93,107],[14,43],[43,58],[64,-12]],[[3759,86256],[220,-54],[27,-226],[-171,-92],[-182,110],[-168,161],[274,101]],[[7436,84829],[185,-40],[117,-183],[-240,-281],[-277,-225],[-142,152],[-43,277],[252,210],[148,90]],[[13740,82958],[-153,223],[-245,188],[-78,515],[-358,478],[-150,558],[-267,38],[-441,15],[-326,170],[-574,613],[-266,112],[-486,211],[-385,-51],[-546,272],[-330,252],[-309,-125],[58,-411],[-154,-38],[-321,-123],[-245,-199],[-308,-126],[-39,348],[125,580],[295,182],[-76,148],[-354,-329],[-190,-394],[-400,-420],[203,-287],[-262,-424],[-299,-248],[-278,-180],[-69,-261],[-434,-305],[-87,-278],[-325,-252],[-191,45],[-259,-165],[-282,-201],[-231,-197],[-477,-169],[-43,99],[304,276],[271,182],[296,324],[345,66],[137,243],[385,353],[62,119],[205,208],[48,448],[141,349],[-320,-179],[-90,102],[-150,-215],[-181,300],[-75,-212],[-104,294],[-278,-236],[-170,0],[-24,352],[50,216],[-179,211],[-361,-113],[-235,277],[-190,142],[-1,334],[-214,252],[108,340],[226,330],[99,303],[225,43],[191,-94],[224,285],[201,-51],[212,183],[-52,270],[-155,106],[205,228],[-170,-7],[-295,-128],[-85,-131],[-219,131],[-392,-67],[-407,142],[-117,238],[-351,343],[390,247],[620,289],[228,0],[-38,-296],[586,23],[-225,366],[-342,225],[-197,296],[-267,252],[-381,187],[155,309],[493,19],[350,270],[66,287],[284,281],[271,68],[526,262],[256,-40],[427,315],[421,-124],[201,-266],[123,114],[469,-35],[-16,-136],[425,-101],[283,59],[585,-186],[534,-56],[214,-77],[370,96],[421,-177],[302,-83]],[[2297,88264],[171,-113],[173,61],[225,-156],[276,-79],[-23,-64],[-211,-125],[-211,128],[-106,107],[-245,-34],[-66,52],[17,223]],[[74266,79657],[-212,-393],[-230,-56],[-13,-592],[-155,-267],[-551,194],[-200,-1058],[-143,-131],[-550,-236],[250,-1026],[-190,-154],[22,-337]],[[72294,75601],[-171,87],[-140,212],[-412,62],[-461,16],[-100,-65],[-396,248],[-158,-122],[-43,-349],[-457,204],[-183,-84],[-62,-259]],[[69711,75551],[-159,-109],[-367,-412],[-121,-422],[-104,-4],[-76,280],[-353,19],[-57,484],[-135,4],[21,593],[-333,431],[-476,-46],[-326,-86],[-265,533],[-227,223],[-431,423],[-52,51],[-715,-349],[11,-2178]],[[65546,74986],[-142,-29],[-195,463],[-188,166],[-315,-123],[-123,-197]],[[64583,75266],[-15,144],[68,246],[-53,206],[-322,202],[-125,530],[-154,150],[-9,192],[270,-56],[11,432],[236,96],[243,-88],[50,576],[-50,365],[-278,-28],[-236,144],[-321,-260],[-259,-124]],[[63639,77993],[-142,96],[29,304],[-177,395],[-207,-17],[-235,401],[160,448],[-81,120],[222,649],[285,-342],[35,431],[573,643],[434,15],[612,-409],[329,-239],[295,249],[440,12],[356,-306],[80,175],[391,-25],[69,280],[-450,406],[267,288],[-52,161],[266,153],[-200,405],[127,202],[1039,205],[136,146],[695,218],[250,245],[499,-127],[88,-612],[290,144],[356,-202],[-23,-322],[267,33],[696,558],[-102,-185],[355,-457],[620,-1500],[148,309],[383,-340],[399,151],[154,-106],[133,-341],[194,-115],[119,-251],[358,79],[147,-361]],[[69711,75551],[83,-58],[-234,-382],[205,-223],[198,147],[329,-311],[-355,-425],[-212,58]],[[69725,74357],[-114,-15],[-40,164],[58,274],[-371,-137],[-89,-380],[-132,-326],[-232,28],[-72,-261],[204,-140],[60,-440],[-156,-598]],[[68841,72526],[-210,124],[-154,4]],[[68477,72654],[7,362],[-369,253],[-291,289],[-181,278],[-317,408],[-137,609],[-93,108],[-301,-27],[-106,121],[-30,471],[-374,312],[-234,-343],[-237,-204],[45,-297],[-313,-8]],[[89166,49043],[482,-407],[513,-338],[192,-302],[154,-297],[43,-349],[462,-365],[68,-313],[-256,-64],[62,-393],[248,-388],[180,-627],[159,20],[-11,-262],[215,-100],[-84,-111],[295,-249],[-30,-171],[-184,-41],[-69,153],[-238,66],[-281,89],[-216,377],[-158,325],[-144,517],[-362,259],[-235,-169],[-170,-195],[35,-436],[-218,-203],[-155,99],[-288,25]],[[89175,45193],[-4,1925],[-5,1925]],[[92399,48417],[106,-189],[33,-307],[-87,-157],[-52,348],[-65,229],[-126,193],[-158,252],[-200,174],[77,143],[150,-166],[94,-130],[117,-142],[111,-248]],[[92027,47129],[-152,-144],[-142,-138],[-148,1],[-228,171],[-158,165],[23,183],[249,-86],[152,46],[42,283],[40,15],[27,-314],[158,45],[78,202],[155,211],[-30,348],[166,11],[56,-97],[-5,-327],[-93,-361],[-146,-48],[-44,-166]],[[92988,47425],[84,-134],[135,-375],[131,-200],[-39,-166],[-78,-59],[-120,227],[-122,375],[-59,450],[38,57],[30,-175]],[[89175,45193],[-247,485],[-282,118],[-69,-168],[-352,-18],[118,481],[175,164],[-72,642],[-134,496],[-538,500],[-229,50],[-417,546],[-82,-287],[-107,-52],[-63,216],[-1,257],[-212,290],[299,213],[198,-11],[-23,156],[-407,1],[-110,352],[-248,109],[-117,293],[374,143],[142,192],[446,-242],[44,-220],[78,-955],[287,-354],[232,627],[319,356],[247,1],[238,-206],[206,-212],[298,-113]],[[84713,45326],[28,-117],[5,-179]],[[84746,45030],[-181,-441],[-238,-130],[-33,71],[25,201],[119,360],[275,235]],[[87280,46506],[-27,445],[49,212],[58,200],[63,-173],[0,-282],[-143,-402]],[[82744,53024],[-158,-533],[204,-560],[-48,-272],[312,-546],[-329,-70],[-93,-403],[12,-535],[-267,-404],[-7,-589],[-107,-903],[-41,210],[-316,-266],[-110,361],[-198,34],[-139,189],[-330,-212],[-101,285],[-182,-32],[-229,68],[-43,793],[-138,164],[-134,505],[-38,517],[32,548],[165,392]],[[80461,51765],[47,-395],[190,-334],[179,121],[177,-43],[162,299],[133,52],[263,-166],[226,126],[143,822],[107,205],[96,672],[319,0],[241,-100]],[[85936,48924],[305,-172],[101,-452],[-234,244],[-232,49],[-157,-39],[-192,21],[65,325],[344,24]],[[85242,48340],[-192,108],[-54,254],[281,29],[69,-195],[-104,-196]],[[85536,51864],[20,-322],[164,-52],[26,-241],[-15,-517],[-143,58],[-42,-359],[114,-312],[-78,-71],[-112,374],[-82,755],[56,472],[92,215]],[[84146,51097],[319,25],[275,429],[48,-132],[-223,-587],[-209,-113],[-267,115],[-463,-29],[-243,-85],[-39,-447],[248,-526],[150,268],[518,201],[-22,-272],[-121,86],[-121,-347],[-245,-229],[263,-757],[-50,-203],[249,-682],[-2,-388],[-148,-173],[-109,207],[134,484],[-273,-229],[-69,164],[36,228],[-200,346],[21,576],[-186,-179],[24,-689],[11,-846],[-176,-85],[-119,173],[79,544],[-43,570],[-117,4],[-86,405],[115,387],[40,469],[139,891],[58,243],[237,439],[217,-174],[350,-82]],[[83414,44519],[-368,414],[259,116],[146,-180],[97,-180],[-17,-159],[-117,-11]],[[83705,45536],[185,45],[249,216],[-41,-328],[-417,-168],[-370,73],[0,216],[220,123],[174,-177]],[[82849,45639],[172,48],[69,-251],[-321,-119],[-193,-79],[-149,5],[95,340],[153,5],[74,209],[100,-158]],[[80134,46785],[38,-210],[533,-59],[61,244],[515,-284],[101,-383],[417,-108],[341,-351],[-317,-225],[-306,238],[-251,-16],[-288,44],[-260,106],[-322,225],[-204,59],[-116,-74],[-506,243],[-48,254],[-255,44],[191,564],[337,-35],[224,-231],[115,-45]],[[78991,49939],[47,-412],[97,-330],[204,-52],[135,-374],[-70,-735],[-11,-914],[-308,-12],[-234,494],[-356,482],[-119,358],[-210,481],[-138,443],[-212,827],[-244,493],[-81,508],[-103,461],[-250,372],[-145,506],[-209,330],[-290,652],[-24,300],[178,-24],[430,-114],[246,-577],[215,-401],[153,-246],[263,-635],[283,-9],[233,-405],[161,-495],[211,-270],[-111,-482],[159,-205],[100,-15]],[[30935,19481],[106,-274],[139,-443],[361,-355],[389,-147],[-125,-296],[-264,-29],[-141,208]],[[31400,18145],[-168,16],[-297,1],[0,1319]],[[33993,32727],[-70,-473],[-74,-607],[3,-588],[-61,-132],[-21,-382]],[[33770,30545],[-19,-308],[353,-506],[-38,-408],[173,-257],[-14,-289],[-267,-757],[-412,-317],[-557,-123],[-305,59],[59,-352],[-57,-442],[51,-298],[-167,-208],[-284,-82],[-267,216],[-108,-155],[39,-587],[188,-178],[152,186],[82,-307],[-255,-183],[-223,-367],[-41,-595],[-66,-316],[-262,-2],[-218,-302],[-80,-443],[273,-433],[266,-119],[-96,-531],[-328,-333],[-180,-692],[-254,-234],[-113,-276],[89,-614],[185,-342],[-117,30]],[[30952,19680],[-257,93],[-672,79],[-115,344],[6,443],[-185,-38],[-98,214],[-24,626],[213,260],[88,375],[-33,299],[148,504],[101,782],[-30,347],[122,112],[-30,223],[-129,118],[92,248],[-126,224],[-65,682],[112,120],[-47,720],[65,605],[75,527],[166,215],[-84,576],[-1,543],[210,386],[-7,494],[159,576],[1,544],[-72,108],[-128,1020],[171,607],[-27,572],[100,537],[182,555],[196,367],[-83,232],[58,190],[-9,985],[302,291],[96,614],[-34,148]],[[31359,37147],[231,534],[364,-144],[163,-427],[109,475],[316,-24],[45,-127]],[[32587,37434],[511,-964],[227,-89],[339,-437],[286,-231],[40,-261],[-273,-898],[280,-160],[312,-91],[220,95],[252,453],[45,521]],[[34826,35372],[138,114],[139,-341],[-6,-472],[-234,-326],[-186,-241],[-314,-573],[-370,-806]],[[31400,18145],[-92,-239],[-238,-183],[-137,19],[-164,48],[-202,177],[-291,86],[-350,330],[-283,317],[-383,662],[229,-124],[390,-395],[369,-212],[143,271],[90,405],[256,244],[198,-70]],[[30669,40193],[136,-402],[37,-426],[146,-250],[-88,-572],[150,-663],[109,-814],[200,81]],[[30952,19680],[-247,4],[-134,-145],[-250,-213],[-45,-552],[-118,-14],[-313,192],[-318,412],[-346,338],[-87,374],[79,346],[-140,393],[-36,1007],[119,568],[293,457],[-422,172],[265,522],[94,982],[309,-208],[145,1224],[-186,157],[-87,-738],[-175,83],[87,845],[95,1095],[127,404],[-80,576],[-22,666],[117,19],[170,954],[192,945],[118,881],[-64,885],[83,487],[-34,730],[163,721],[50,1143],[89,1227],[87,1321],[-20,967],[-58,832]],[[30452,39739],[143,151],[74,303]],[[58538,45652],[-109,60],[-373,-99],[-75,-71],[-79,-377],[62,-261],[-49,-699],[-34,-593],[75,-105],[194,-230],[76,107],[23,-637],[-212,5],[-114,325],[-103,252],[-213,82],[-62,310],[-170,-187],[-222,83],[-93,268],[-176,55],[-131,-15],[-15,184],[-96,15]],[[56642,44124],[-127,35],[-172,-89],[-121,15],[-68,-54],[15,703],[-93,219],[-21,363],[41,356],[-56,228],[-5,372],[-337,-5],[24,213],[-142,-2],[-15,-103],[-172,-23],[-69,-344],[-42,-148],[-154,83],[-91,-83],[-184,-47],[-106,309],[-64,191],[-80,354],[-68,440],[-820,8],[-98,-71],[-80,11],[-115,-79]],[[53422,46976],[-39,183]],[[53383,47159],[71,62],[9,258],[45,152],[101,124]],[[53609,47755],[73,-60],[95,226],[152,-6],[17,-167],[104,-105],[164,370],[161,289],[71,189],[-10,486],[121,574],[127,304],[183,285],[32,189],[7,216],[45,205],[-14,335],[34,524],[55,368],[83,316],[16,357]],[[55125,52650],[25,412],[108,300],[149,190],[229,-200],[177,-218],[203,-59],[207,-115],[83,357],[38,46],[127,-60],[309,295],[110,-125],[90,18],[41,143],[104,51],[209,-62],[178,-14],[91,63]],[[57603,53672],[169,-488],[124,-71],[75,99],[128,-39],[155,125],[66,-252],[244,-393]],[[58564,52653],[-16,-691],[111,-80],[-89,-210],[-107,-157],[-106,-308],[-59,-274],[-15,-475],[-65,-225],[-2,-446]],[[58216,49787],[-80,-165],[-10,-351],[-38,-46],[-26,-323]],[[58062,48902],[70,-268],[17,-713]],[[61551,49585],[-165,488],[-3,2152],[243,670]],[[61626,52895],[76,186],[178,11],[247,417],[362,26],[785,1773]],[[63274,55308],[194,493],[125,363],[0,308],[0,596],[1,244],[2,9]],[[63596,57321],[89,12],[128,88],[147,59],[132,202],[105,2],[6,-163],[-25,-344],[1,-310],[-59,-214],[-78,-639],[-134,-659],[-172,-755],[-238,-866],[-237,-661],[-327,-806],[-278,-479],[-415,-586],[-259,-450],[-304,-715],[-64,-312],[-63,-140]],[[59417,50018],[-3,627],[80,239],[137,391],[101,431],[-123,678],[-32,296],[-132,411]],[[59445,53091],[171,352],[188,390]],[[59804,53833],[145,-99],[0,-332],[95,-194],[193,0],[352,-502],[87,-6],[65,16],[62,-68],[185,-47],[82,247],[254,247],[112,-200],[190,0]],[[61551,49585],[-195,-236],[-68,-246],[-104,-44],[-40,-416],[-89,-238],[-54,-393],[-112,-195]],[[56824,55442],[-212,258],[-96,170],[-18,184],[45,246],[-1,241],[-160,369],[-31,253]],[[56351,57163],[3,143],[-102,174],[-3,343],[-58,228],[-98,-34],[28,217],[72,246],[-32,245],[92,181],[-58,138],[73,365],[127,435],[240,-41],[-14,2345]],[[56621,62148],[3,248],[320,2],[0,1180]],[[56944,63578],[1117,0],[1077,0],[1102,0]],[[60240,63578],[90,-580],[-61,-107],[40,-608],[102,-706],[106,-145],[152,-219]],[[60669,61213],[-141,-337],[-204,-97],[-88,-181],[-27,-393],[-120,-868],[30,-236]],[[60119,59101],[-45,-508],[-112,-582],[-168,-293],[-119,-451],[-28,-241],[-132,-166],[-82,-618],[4,-531]],[[59437,55711],[-3,460],[-39,12],[5,294],[-33,203],[-143,233],[-34,426],[34,436],[-129,41],[-19,-132],[-167,-30],[67,-173],[23,-355],[-152,-324],[-138,-426],[-144,-61],[-233,345],[-105,-122],[-29,-172],[-143,-112],[-9,-122],[-277,0],[-38,122],[-200,20],[-100,-101],[-77,51],[-143,344],[-48,163],[-200,-81],[-76,-274],[-72,-528],[-95,-111],[-85,-65],[189,-230]],[[56351,57163],[-176,-101],[-141,-239],[-201,-645],[-261,-273],[-269,36],[-78,-54],[28,-208],[-145,-207],[-118,-230],[-350,-226],[-69,134],[-46,11],[-52,-152],[-229,-44]],[[54244,54965],[43,160],[-87,407],[-39,245],[-121,100],[-164,345],[60,279],[127,-60],[78,42],[155,-6],[-151,537],[10,393],[-18,392],[-111,378]],[[54026,58177],[28,279],[-178,13],[0,380],[-115,219],[120,778],[354,557],[15,769],[107,1199],[60,254],[-116,203],[-4,188],[-104,153],[-68,919]],[[54125,64088],[280,323],[1108,-1132],[1108,-1131]],[[30080,62227],[24,-321],[-21,-228],[-68,-99],[71,-177],[-5,-161]],[[30081,61241],[-185,100],[-131,-41],[-169,43],[-130,-110],[-149,184],[24,190],[256,-82],[210,-47],[100,131],[-127,256],[2,226],[-175,92],[62,163],[170,-26],[241,-93]],[[30080,62227],[34,101],[217,-3],[165,-152],[73,15],[50,-209],[152,11],[-9,-176],[124,-21],[136,-217],[-103,-240],[-132,128],[-127,-25],[-92,28],[-50,-107],[-106,-37],[-43,144],[-92,-85],[-111,-405],[-71,94],[-14,170]],[[76049,98451],[600,133],[540,-297],[640,-572],[-69,-531],[-606,-73],[-773,170],[-462,226],[-213,423],[-379,117],[722,404]],[[78565,97421],[704,-336],[-82,-240],[-1566,-228],[507,776],[229,66],[208,-38]],[[88563,95563],[734,-26],[1004,-313],[-219,-439],[-1023,16],[-461,-139],[-550,384],[149,406],[366,111]],[[91172,95096],[697,-155],[-321,-234],[-444,53],[-516,233],[66,192],[518,-89]],[[88850,93928],[263,234],[348,54],[394,-226],[34,-155],[-421,-4],[-569,66],[-49,31]],[[62457,98194],[542,107],[422,8],[57,-160],[159,142],[262,97],[412,-129],[-107,-90],[-373,-78],[-250,-45],[-39,-97],[-324,-98],[-301,140],[158,185],[-618,18]],[[56314,82678],[-511,-9],[-342,67]],[[55461,82736],[63,260],[383,191]],[[55907,83187],[291,-103],[123,-94],[-30,-162],[23,-150]],[[64863,94153],[665,518],[-75,268],[621,312],[917,380],[925,110],[475,220],[541,76],[193,-233],[-187,-184],[-984,-293],[-848,-282],[-863,-562],[-414,-577],[-435,-568],[56,-491],[531,-484],[-164,-52],[-907,77],[-74,262],[-503,158],[-40,320],[284,126],[-10,323],[551,503],[-255,73]],[[89698,82309],[96,-569],[-7,-581],[114,-597],[280,-1046],[-411,195],[-171,-854],[271,-605],[-8,-413],[-211,356],[-182,-457],[-51,496],[31,575],[-32,638],[64,446],[13,790],[-163,581],[24,808],[257,271],[-110,274],[123,83],[73,-391]],[[86327,75524],[-39,104]],[[86288,75628],[-2,300],[142,16],[40,698],[-73,506],[238,208],[338,-104],[186,575],[96,647],[107,216],[146,532],[-459,-175],[-240,-233],[-423,1],[-112,555],[-329,420],[-483,189],[-103,579],[-97,363],[-104,254],[-172,596],[-244,217],[-415,176],[-369,-16],[-345,-106],[-229,-294],[152,-141],[4,-326],[-155,-189],[-251,-627],[3,-260],[-392,-373],[-333,223]],[[82410,80055],[-331,-49],[-146,198],[-166,63],[-407,-416],[-366,-98],[-255,-146],[-350,96],[-258,-6],[-168,302],[-272,284],[-279,78],[-351,-78],[-263,-109],[-394,248],[-53,443],[-327,152],[-252,69],[-311,244],[-288,-612],[113,-348],[-270,-411],[-402,148],[-277,22],[-186,276],[-289,8],[-242,182],[-423,-278],[-530,-509],[-292,-102]],[[74375,79706],[-109,-49]],[[63639,77993],[-127,-350],[-269,-97],[-276,-610],[252,-561],[-27,-398],[303,-696]],[[63495,75281],[-166,-238],[-48,-150],[-122,40],[-191,359],[-78,20]],[[62890,75312],[-175,137],[-85,242],[-259,124],[-169,-93],[-48,110],[-378,283],[-409,96],[-235,101],[-34,-70]],[[61098,76242],[-354,499],[-317,223],[-240,347],[202,95],[231,494],[-156,234],[410,241],[-8,129],[-249,-95]],[[60617,78409],[9,262],[143,165],[269,43],[44,197],[-62,326],[113,310],[-3,173],[-410,192],[-162,-6],[-172,277],[-213,-94],[-352,208],[6,116],[-99,256],[-222,29],[-23,183],[70,120],[-178,334],[-288,-57],[-84,30],[-70,-134],[-104,23]],[[58829,81362],[-68,379],[-66,196],[54,55],[224,-20],[108,129],[-80,157],[-187,104],[16,107],[-113,108],[-174,387],[60,159],[-27,277],[-272,141],[-146,-70],[-39,146],[-293,149]],[[57826,83766],[-89,348],[-24,287],[-134,136]],[[57579,84537],[120,187],[-83,551],[198,341],[-42,103]],[[57772,85719],[316,327],[-291,280]],[[57797,86326],[594,755],[258,341],[105,301],[-411,405],[113,385],[-250,440],[187,506],[-323,673],[256,445],[-425,394],[41,414]],[[57942,91385],[224,54],[473,237]],[[58639,91676],[286,206],[456,-358],[761,-140],[1050,-668],[213,-281],[18,-393],[-308,-311],[-454,-157],[-1240,449],[-204,-75],[453,-433],[18,-274],[18,-604],[358,-180],[217,-153],[36,286],[-168,254],[177,224],[672,-368],[233,144],[-186,433],[647,578],[256,-34],[260,-206],[161,406],[-231,352],[136,353],[-204,367],[777,-190],[158,-331],[-351,-73],[1,-328],[219,-203],[429,128],[68,377],[580,282],[970,507],[209,-29],[-273,-359],[344,-61],[199,202],[521,16],[412,245],[317,-356],[315,391],[-291,343],[145,195],[820,-179],[385,-185],[1006,-675],[186,309],[-282,313],[-8,125],[-335,58],[92,280],[-149,461],[-8,189],[512,535],[183,537],[206,116],[736,-156],[57,-328],[-263,-479],[173,-189],[89,-413],[-63,-809],[307,-362],[-120,-395],[-544,-839],[318,-87],[110,213],[306,151],[74,293],[240,281],[-162,336],[130,390],[-304,49],[-67,328],[222,593],[-361,482],[497,398],[-64,421],[139,13],[145,-328],[-109,-570],[297,-108],[-127,426],[465,233],[577,31],[513,-337],[-247,492],[-28,630],[483,119],[669,-26],[602,77],[-226,309],[321,388],[319,16],[540,293],[734,79],[93,162],[729,55],[227,-133],[624,314],[510,-10],[77,255],[265,252],[656,242],[476,-191],[-378,-146],[629,-90],[75,-292],[254,143],[812,-7],[626,-289],[223,-221],[-69,-307],[-307,-175],[-730,-328],[-209,-175],[345,-83],[410,-149],[251,112],[141,-379],[122,153],[444,93],[892,-97],[67,-276],[1162,-88],[15,451],[590,-104],[443,4],[449,-312],[128,-378],[-165,-247],[349,-465],[437,-240],[268,620],[446,-266],[473,159],[538,-182],[204,166],[455,-83],[-201,549],[367,256],[2509,-384],[236,-351],[727,-451],[1122,112],[553,-98],[231,-244],[-33,-432],[342,-168],[372,121],[492,15],[525,-116],[526,66],[484,-526],[344,189],[-224,378],[123,262],[886,-165],[578,36],[799,-282],[-99610,-258],[681,-451],[728,-588],[-24,-367],[187,-147],[-64,429],[754,-88],[544,-553],[-276,-257],[-455,-61],[-7,-578],[-111,-122],[-260,17],[-212,206],[-369,172],[-62,257],[-283,96],[-315,-76],[-151,207],[60,219],[-333,-140],[126,-278],[-158,-251],[99997,-3],[-357,-260],[-360,44],[250,-315],[166,-487],[128,-159],[32,-244],[-71,-157],[-518,129],[-777,-445],[-247,-69],[-425,-415],[-403,-362],[-102,-269],[-397,409],[-724,-464],[-126,219],[-268,-253],[-371,81],[-90,-388],[-333,-572],[10,-239],[316,-132],[-37,-860],[-258,-22],[-119,-494],[116,-255],[-486,-302],[-96,-674],[-415,-144],[-83,-600],[-400,-551],[-103,407],[-119,862],[-155,1313],[134,819],[234,353],[14,276],[432,132],[496,744],[479,608],[499,471],[223,833],[-337,-50],[-167,-487],[-705,-649],[-227,727],[-717,-201],[-696,-990],[230,-362],[-620,-154],[-430,-61],[20,427],[-431,90],[-344,-291],[-850,102],[-914,-175],[-899,-1153],[-1065,-1394],[438,-74],[136,-370],[270,-132],[178,295],[305,-38],[401,-650],[9,-503],[-217,-590],[-23,-705],[-126,-945],[-418,-855],[-94,-409],[-377,-688],[-374,-682],[-179,-349],[-370,-346],[-175,-8],[-175,287],[-373,-432],[-43,-197]],[[0,92833],[36,24],[235,-1],[402,-169],[-24,-81],[-286,-141],[-363,-36],[99694,-30],[-49,187],[-99645,247]],[[59287,77741],[73,146],[198,-127],[89,-23],[36,-117],[42,-18]],[[59725,77602],[2,-51],[136,-142],[284,35],[-55,-210],[-304,-103],[-377,-342],[-154,121],[61,277],[-304,173],[50,113],[265,197],[-42,71]],[[28061,66408],[130,47],[184,-18],[8,-153],[-303,-95],[-19,219]],[[28391,66555],[220,-265],[-48,-420],[-51,75],[4,309],[-124,234],[-1,67]],[[28280,65474],[84,-23],[97,-491],[1,-343],[-68,-29],[-70,340],[-104,171],[60,375]],[[33000,19946],[333,354],[236,-148],[167,237],[222,-266],[-83,-207],[-375,-177],[-125,207],[-236,-266],[-139,266]],[[54206,97653],[105,202],[408,20],[350,-206],[915,-440],[-699,-233],[-155,-435],[-243,-111],[-132,-490],[-335,-23],[-598,361],[252,210],[-416,170],[-541,499],[-216,463],[757,212],[152,-207],[396,8]],[[57942,91385],[117,414],[-356,235],[-431,-200],[-137,-433],[-265,-262],[-298,143],[-362,-29],[-309,312],[-167,-156]],[[55734,91409],[-172,-24],[-41,-389],[-523,95],[-74,-329],[-267,2],[-183,-421],[-278,-655],[-431,-831],[101,-202],[-97,-234],[-275,10],[-180,-554],[17,-784],[177,-300],[-92,-694],[-231,-405],[-122,-341]],[[53063,85353],[-187,363],[-548,-684],[-371,-138],[-384,301],[-99,635],[-88,1363],[256,381],[733,496],[549,609],[508,824],[668,1141],[465,444],[763,741],[610,259],[457,-31],[423,489],[506,-26],[499,118],[869,-433],[-358,-158],[305,-371]],[[57613,97879],[-412,-318],[-806,-70],[-819,98],[-50,163],[-398,11],[-304,271],[858,165],[403,-142],[281,177],[702,-148],[545,-207]],[[56867,96577],[-620,-241],[-490,137],[191,152],[-167,189],[575,119],[110,-222],[401,-134]],[[37010,99398],[932,353],[975,-27],[354,218],[982,57],[2219,-74],[1737,-469],[-513,-227],[-1062,-26],[-1496,-58],[140,-105],[984,65],[836,-204],[540,181],[231,-212],[-305,-344],[707,220],[1348,229],[833,-114],[156,-253],[-1132,-420],[-157,-136],[-888,-102],[643,-28],[-324,-431],[-224,-383],[9,-658],[333,-386],[-434,-24],[-457,-187],[513,-313],[65,-502],[-297,-55],[360,-508],[-617,-42],[322,-241],[-91,-208],[-391,-91],[-388,-2],[348,-400],[4,-263],[-549,244],[-143,-158],[375,-148],[364,-361],[105,-476],[-495,-114],[-214,228],[-344,340],[95,-401],[-322,-311],[732,-25],[383,-32],[-745,-515],[-755,-466],[-813,-204],[-306,-2],[-288,-228],[-386,-624],[-597,-414],[-192,-24],[-370,-145],[-399,-138],[-238,-365],[-4,-415],[-141,-388],[-453,-472],[112,-462],[-125,-488],[-142,-577],[-391,-36],[-410,482],[-556,3],[-269,324],[-186,577],[-481,735],[-141,385],[-38,530],[-384,546],[100,435],[-186,208],[275,691],[418,220],[110,247],[58,461],[-318,-209],[-151,-88],[-249,-84],[-341,193],[-19,401],[109,314],[258,9],[567,-157],[-478,375],[-249,202],[-276,-83],[-232,147],[310,550],[-169,220],[-220,409],[-335,626],[-353,230],[3,247],[-745,346],[-590,43],[-743,-24],[-677,-44],[-323,188],[-482,372],[729,186],[559,31],[-1188,154],[-627,241],[39,229],[1051,285],[1018,284],[107,214],[-750,213],[243,235],[961,413],[404,63],[-115,265],[658,156],[854,93],[853,5],[303,-184],[737,325],[663,-221],[390,-46],[577,-192],[-660,318],[38,253]],[[69148,21851],[179,-186],[263,-74],[9,-112],[-77,-269],[-427,-38],[-7,314],[41,244],[19,121]],[[84713,45326],[32,139],[239,133],[194,20],[87,74],[105,-74],[-102,-160],[-289,-258],[-233,-170]],[[54540,33696],[133,292],[109,-162],[47,-252],[125,-43],[175,-112],[149,43],[248,302],[0,2182]],[[55526,35946],[75,-88],[165,-562],[-26,-360],[62,-207],[199,60],[139,264],[132,177],[68,283],[135,137],[117,-71],[133,-166],[226,-29],[178,138],[28,184],[48,283],[152,47],[83,222],[93,393],[249,442],[393,435]],[[58175,37528],[113,-7],[134,-100],[94,71],[148,-59]],[[58664,37433],[133,-832],[72,-419],[-49,-659],[23,-212]],[[58843,35311],[-140,108],[-80,-42],[-26,-172],[-76,-222],[2,-204],[166,-320],[163,63],[56,263]],[[58908,34785],[211,-5]],[[59119,34780],[-70,-430],[-32,-491],[-72,-267],[-190,-298],[-54,-86],[-118,-300],[-77,-303],[-158,-424],[-314,-609],[-196,-355],[-210,-269],[-290,-229],[-141,-31],[-36,-164],[-169,88],[-138,-113],[-301,114],[-168,-72],[-115,31],[-286,-233],[-238,-94],[-171,-223],[-127,-14],[-117,210],[-94,11],[-120,264],[-13,-82],[-37,159],[2,346],[-90,396],[89,108],[-7,453],[-182,553],[-139,501],[-1,1],[-199,768]],[[58049,33472],[-121,182],[-130,-120],[-151,-232],[-148,-374],[209,-454],[99,59],[51,188],[155,93],[47,192],[85,288],[-96,178]],[[23016,65864],[-107,-518],[-49,-426],[-20,-791],[-27,-289],[48,-322],[86,-288],[56,-458],[184,-440],[65,-337],[109,-291],[295,-157],[114,-247],[244,165],[212,60],[208,106],[175,101],[176,241],[67,345],[22,496],[48,173],[188,155],[294,137],[246,-21],[169,50],[66,-125],[-9,-285],[-149,-351],[-66,-360],[51,-103],[-42,-255],[-69,-461],[-71,152],[-58,-10]],[[25472,61510],[-53,-8],[-99,-357],[-51,70],[-33,-27],[2,-87]],[[25238,61101],[-257,7],[-259,-1],[-1,-333],[-125,-1],[103,-198],[103,-136],[31,-128],[45,-36],[-7,-201],[-357,-2],[-133,-481],[39,-111],[-32,-138],[-7,-172]],[[24381,59170],[-314,636],[-144,191],[-226,155],[-156,-43],[-223,-223],[-140,-58],[-196,156],[-208,112],[-260,271],[-208,83],[-314,275],[-233,282],[-70,158],[-155,35],[-284,187],[-116,270],[-299,335],[-139,373],[-66,288],[93,57],[-29,169],[64,153],[1,204],[-93,266],[-25,235],[-94,298],[-244,587],[-280,462],[-135,368],[-238,241],[-51,145],[42,365],[-142,138],[-164,287],[-69,412],[-149,48],[-162,311],[-130,288],[-12,184],[-149,446],[-99,452],[5,227],[-201,234],[-93,-25],[-159,163],[-44,-240],[46,-284],[27,-444],[95,-243],[206,-407],[46,-139],[42,-42],[37,-203],[49,8],[56,-381],[85,-150],[59,-210],[174,-300],[92,-550],[83,-259],[77,-277],[15,-311],[134,-20],[112,-268],[100,-264],[-6,-106],[-117,-217],[-49,3],[-74,359],[-181,337],[-201,286],[-142,150],[9,432],[-42,320],[-132,183],[-191,264],[-37,-76],[-70,154],[-171,143],[-164,343],[20,44],[115,-33],[103,221],[10,266],[-214,422],[-163,163],[-102,369],[-103,388],[-129,472],[-113,531]],[[33993,32727],[180,63],[279,-457],[103,18],[286,-379],[218,-327],[160,-402],[-122,-280],[77,-334]],[[35174,30629],[-121,-372],[-313,-328],[-205,118],[-151,-63],[-256,253],[-189,-19],[-169,327]],[[34826,35372],[54,341],[38,350],[0,325],[-100,107],[-104,-96],[-103,26],[-33,228],[-26,541],[-52,177],[-187,160],[-114,-116],[-293,113],[18,802],[-82,329]],[[33842,38659],[87,122],[-27,337],[77,259],[49,465],[-66,367],[-151,166],[-30,233],[41,342],[-533,24],[-107,688],[81,10],[-3,255],[-55,172],[-12,342],[-161,175],[-175,-6],[-115,172],[-188,117],[-109,220],[-311,98],[-302,529],[23,396],[-34,227],[29,443],[-363,-100],[-147,-222],[-243,-239],[-62,-179],[-143,-13],[-206,50]],[[30686,44109],[-157,-102],[-126,68],[18,898],[-228,-348],[-245,15],[-105,315],[-184,34],[59,254],[-155,359],[-115,532],[73,108],[0,250],[168,171],[-28,319],[71,206],[20,275],[318,402],[227,114],[37,89],[251,-28]],[[30585,48040],[125,1620],[6,256],[-43,339],[-123,215],[1,430],[156,97],[56,-61],[9,226],[-162,61],[-4,370],[541,-13],[92,203],[77,-187],[55,-349],[52,73]],[[31423,51320],[153,-312],[216,38],[54,181],[206,138],[115,97],[32,250],[198,168],[-15,124],[-235,51],[-39,372],[12,396],[-125,153],[52,55],[206,-76],[221,-148],[80,140],[200,92],[310,221],[102,225],[-37,167]],[[33129,53652],[145,26],[64,-136],[-36,-259],[96,-90],[63,-274],[-77,-209],[-44,-502],[71,-299],[20,-274],[171,-277],[137,-29],[30,116],[88,25],[126,104],[90,157],[154,-50],[67,21]],[[34294,51702],[151,-48],[25,120],[-46,118],[28,171],[112,-53],[131,61],[159,-125]],[[34854,51946],[121,-122],[86,160],[62,-25],[38,-166],[133,42],[107,224],[85,436],[164,540]],[[35650,53035],[95,28],[69,-327],[155,-1033],[149,-97],[7,-408],[-208,-487],[86,-178],[491,-92],[10,-593],[211,388],[349,-212],[462,-361],[135,-346],[-45,-327],[323,182],[540,-313],[415,23],[411,-489],[355,-662],[214,-170],[237,-24],[101,-186],[94,-752],[46,-358],[-110,-977],[-142,-385],[-391,-822],[-177,-668],[-206,-513],[-69,-11],[-78,-435],[20,-1107],[-77,-910],[-30,-390],[-88,-233],[-49,-790],[-282,-771],[-47,-610],[-225,-256],[-65,-355],[-302,2],[-437,-227],[-195,-263],[-311,-173],[-327,-470],[-235,-586],[-41,-441],[46,-326],[-51,-597],[-63,-289],[-195,-325],[-308,-1040],[-244,-468],[-189,-277],[-127,-562],[-183,-337]],[[33842,38659],[-4,182],[-259,302],[-258,9],[-484,-172],[-133,-520],[-7,-318],[-110,-708]],[[30669,40193],[175,638],[-119,496],[63,199],[-49,219],[108,295],[6,503],[13,415],[60,200],[-240,951]],[[30452,39739],[-279,340],[-24,242],[-551,593],[-498,646],[-214,365],[-115,488],[46,170],[-236,775],[-274,1090],[-262,1177],[-114,269],[-87,435],[-216,386],[-198,239],[90,264],[-134,563],[86,414],[221,373]],[[27693,48568],[33,-246],[-79,-141],[8,-216],[114,47],[113,-64],[116,-298],[157,243],[53,398],[170,514],[334,233],[303,619],[86,384],[-38,449]],[[29063,50490],[74,56],[184,-280],[89,-279],[129,-152],[163,-620],[207,-74],[153,157],[101,-103],[166,51],[213,-276],[-179,-602],[83,-14],[139,-314]],[[29063,50490],[-119,140],[-137,195],[-79,-94],[-235,82],[-68,255],[-52,-10],[-278,338]],[[28095,51396],[-37,183],[103,44],[-12,296],[65,214],[138,40],[117,371],[106,310],[-102,141],[52,343],[-62,540],[59,155],[-44,500],[-112,315]],[[28366,54848],[36,287],[89,-43],[52,176],[-64,348],[34,86]],[[28513,55702],[143,-18],[209,412],[114,63],[3,195],[51,500],[159,274],[175,11],[22,123],[218,-49],[218,298],[109,132],[134,285],[98,-36],[73,-156],[-54,-199]],[[30185,57537],[-178,-99],[-71,-295],[-107,-169],[-81,-220],[-34,-422],[-77,-345],[144,-40],[35,-271],[62,-130],[21,-238],[-33,-219],[10,-123],[69,-49],[66,-207],[357,57],[161,-75],[196,-508],[112,63],[200,-32],[158,68],[99,-102],[-50,-318],[-62,-199],[-22,-423],[56,-393],[79,-175],[9,-133],[-140,-294],[100,-130],[74,-207],[85,-589]],[[28366,54848],[-93,170],[-59,319],[68,158],[-70,40],[-52,196],[-138,164],[-122,-38],[-56,-205],[-112,-149],[-61,-20],[-27,-123],[132,-321],[-75,-76],[-40,-87],[-130,-30],[-48,353],[-36,-101],[-92,35],[-56,238],[-114,39],[-72,69],[-119,-1],[-8,-128],[-32,89]],[[26954,55439],[14,117],[23,120],[-10,107],[41,70],[-58,88],[-1,238],[107,53]],[[27070,56232],[100,-212],[-6,-126],[111,-26],[26,48],[77,-145],[136,42],[119,150],[168,119],[95,176],[153,-34],[-10,-58],[155,-21],[124,-102],[90,-177],[105,-164]],[[26954,55439],[-151,131],[-56,124],[32,103],[-11,130],[-77,142],[-109,116],[-95,76],[-19,173],[-73,105],[18,-172],[-55,-141],[-64,164],[-89,58],[-38,120],[2,179],[36,187],[-78,83],[64,114]],[[26191,57131],[42,76],[183,-156],[63,77],[89,-50],[46,-121],[82,-40],[66,126]],[[26762,57043],[70,-321],[108,-238],[130,-252]],[[26191,57131],[-96,186],[-130,238],[-61,200],[-117,185],[-140,267],[31,91],[46,-88],[21,41]],[[25745,58251],[86,25],[35,135],[41,5],[-6,290],[65,14],[58,-4],[60,158],[82,-120],[29,74],[51,70],[97,163],[4,121],[27,-5],[36,141],[29,17],[47,-90],[56,-27],[61,76],[70,0],[97,77],[38,81],[95,-12]],[[26903,59440],[-24,-57],[-14,-132],[29,-216],[-64,-202],[-30,-237],[-9,-261],[15,-152],[7,-266],[-43,-58],[-26,-253],[19,-156],[-56,-151],[12,-159],[43,-97]],[[25745,58251],[-48,185],[-84,51]],[[25613,58487],[19,237],[-38,64],[-57,42],[-122,-70],[-10,79],[-84,95],[-60,118],[-82,50]],[[25179,59102],[58,150],[-22,116],[20,113],[131,166],[127,225]],[[25493,59872],[29,-23],[61,104],[79,8],[26,-48],[43,29],[129,-53],[128,15],[90,66],[32,66],[89,-31],[66,-40],[73,14],[55,51],[127,-82],[44,-13],[85,-110],[80,-132],[101,-91],[73,-162]],[[25613,58487],[-31,-139],[-161,9],[-100,57],[-115,117],[-154,37],[-79,127]],[[24973,58695],[9,86],[95,149],[52,66],[-15,69],[65,37]],[[25238,61101],[-2,-468],[-22,-667],[83,0]],[[25297,59966],[90,-107],[24,88],[82,-75]],[[24973,58695],[-142,103],[-174,11],[-127,117],[-149,244]],[[25472,61510],[1,-87],[53,-3],[-5,-160],[-45,-256],[24,-91],[-29,-212],[18,-56],[-32,-299],[-55,-156],[-50,-19],[-55,-205]],[[30185,57537],[-8,-139],[-163,-69],[91,-268],[-3,-309],[-123,-344],[105,-468],[120,38],[62,427],[-86,208],[-14,447],[346,241],[-38,278],[97,186],[100,-415],[195,-9],[180,-330],[11,-195],[249,-6],[297,61],[159,-264],[213,-74],[155,185],[4,149],[344,35],[333,9],[-236,-175],[95,-279],[222,-44],[210,-291],[45,-473],[144,13],[109,-139]],[[33400,55523],[-220,-347],[-24,-215],[95,-220],[-69,-110],[-171,-95],[5,-273],[-75,-163],[188,-448]],[[33400,55523],[183,-217],[171,-385],[8,-304],[105,-14],[149,-289],[109,-205]],[[34125,54109],[-44,-532],[-169,-154],[15,-139],[-51,-305],[123,-429],[89,-1],[37,-333],[169,-514]],[[34125,54109],[333,-119],[30,107],[225,43],[298,-159]],[[35011,53981],[-144,-508],[22,-404],[109,-351],[-49,-254],[-24,-270],[-71,-248]],[[35011,53981],[95,-65],[204,-140],[294,-499],[46,-242]],[[51718,79804],[131,-155],[400,-109],[-140,-404],[-35,-421]],[[52074,78715],[-77,-101],[-126,54],[9,-150],[-203,-332],[-5,-267],[133,92],[95,-259]],[[51900,77752],[-11,-167],[82,-222],[-97,-180],[72,-457],[151,-75],[-32,-256]],[[52065,76395],[-252,-334],[-548,160],[-404,-192],[-32,-355]],[[50829,75674],[-322,-77],[-313,267],[-101,-127],[-511,268],[-111,230]],[[49471,76235],[144,354],[53,1177],[-287,620],[-205,299],[-424,227],[-28,431],[360,129],[466,-152],[-88,669],[263,-254],[646,461],[84,484],[243,119]],[[50698,80799],[40,-207],[129,-10],[129,-237],[194,-279],[143,46],[243,-269]],[[51576,79843],[62,-52],[80,13]],[[52429,75765],[179,226],[47,-507],[-92,-456],[-126,120],[-64,398],[56,219]],[[27693,48568],[148,442],[-60,258],[-106,-275],[-166,259],[56,167],[-47,536],[97,89],[52,368],[105,381],[-20,241],[153,126],[190,236]],[[31588,61519],[142,-52],[50,-118],[-71,-149],[-209,4],[-163,-21],[-16,253],[40,86],[227,-3]],[[28453,61504],[187,-53],[147,-142],[46,-161],[-195,-11],[-84,-99],[-156,95],[-159,215],[34,135],[116,41],[64,-20]],[[27147,64280],[240,-42],[219,-7],[261,-201],[110,-216],[260,66],[98,-138],[235,-366],[173,-267],[92,8],[165,-120],[-20,-167],[205,-24],[210,-242],[-33,-138],[-185,-75],[-187,-29],[-191,46],[-398,-57],[186,329],[-113,154],[-179,39],[-96,171],[-66,336],[-157,-23],[-259,159],[-83,124],[-362,91],[-97,115],[104,148],[-273,30],[-199,-307],[-115,-8],[-40,-144],[-138,-65],[-118,56],[146,183],[60,213],[126,131],[142,116],[210,56],[67,65]],[[58175,37528],[-177,267],[-215,90],[-82,375],[0,208],[-119,64],[-315,649],[-87,342],[-56,105],[-107,473]],[[57017,40101],[311,-65],[90,-68],[94,13],[154,383],[241,486],[100,46],[33,205],[159,235],[210,81]],[[58409,41417],[18,-220],[232,12],[128,-125],[60,-146],[132,-43],[145,-190],[0,-748],[-54,-409],[-12,-442],[45,-175],[-31,-348],[-42,-53],[-74,-426],[-292,-671]],[[55526,35946],[0,1725],[274,20],[8,2105],[207,19],[428,207],[106,-243],[177,231],[85,2],[156,133]],[[56967,40145],[50,-44]],[[54540,33696],[-207,446],[-108,432],[-62,575],[-68,428],[-93,910],[-7,707],[-35,322],[-108,243],[-144,489],[-146,708],[-60,371],[-226,577],[-17,453]],[[53259,40357],[134,113],[166,100],[180,-17],[166,-267],[42,41],[1126,26],[192,-284],[673,-83],[510,241]],[[56448,40227],[228,134],[180,-34],[109,-133],[2,-49]],[[45357,58612],[-115,460],[-138,210],[122,112],[134,415],[66,304]],[[45426,60113],[96,189],[138,-51],[135,129],[155,6],[133,-173],[184,-157],[168,-435],[184,-405]],[[46619,59216],[13,-368],[54,-338],[104,-166],[24,-229],[-13,-184]],[[46801,57931],[-40,-33],[-151,47],[-21,-66],[-61,-13],[-200,144],[-134,6]],[[46194,58016],[-513,25],[-75,-67],[-92,19],[-147,-96]],[[45367,57897],[-46,453]],[[45321,58350],[253,-13],[67,83],[50,5],[103,136],[119,-124],[121,-11],[120,133],[-56,170],[-92,-99],[-86,3],[-110,145],[-88,-9],[-63,-140],[-302,-17]],[[46619,59216],[93,107],[47,348],[88,14],[194,-165],[157,117],[107,-39],[42,131],[1114,9],[62,414],[-48,73],[-134,2550],[-134,2550],[425,10]],[[48632,65335],[937,-1289],[937,-1289],[66,-277],[173,-169],[129,-96],[3,-376],[308,58]],[[51185,61897],[1,-1361],[-152,-394],[-24,-364],[-247,-94],[-379,-51],[-102,-210],[-178,-23]],[[50104,59400],[-178,-3],[-70,114],[-153,-84],[-259,-246],[-53,-184],[-216,-265],[-38,-152],[-116,-120],[-134,79],[-76,-144],[-41,-405],[-221,-490],[7,-200],[-76,-250],[18,-343]],[[48498,56707],[-114,-88],[-65,-74],[-43,253],[-80,-67],[-48,11],[-51,-172],[-215,5],[-77,89],[-36,-54]],[[47769,56610],[-85,170],[15,176],[-35,69],[-59,-58],[11,192],[57,152],[-114,248],[-33,163],[-62,130],[-55,15],[-67,-83],[-90,-79],[-76,-128],[-119,48],[-77,150],[-46,19],[-73,-78],[-44,-1],[-16,216]],[[47587,66766],[1045,-1431]],[[45426,60113],[-24,318],[78,291],[34,557],[-30,583],[-34,294],[28,295],[-72,281],[-146,255]],[[50747,54278],[-229,-69]],[[50518,54209],[-69,407],[13,1357],[-56,122],[-11,290],[-96,207],[-85,174],[35,311]],[[50249,57077],[96,67],[56,258],[136,56],[61,176]],[[50598,57634],[93,173],[100,2],[212,-340]],[[51003,57469],[-11,-197],[62,-350],[-54,-238],[29,-159],[-135,-366],[-86,-181],[-52,-372],[7,-376],[-16,-952]],[[54026,58177],[-78,-34],[-9,-188]],[[53939,57955],[-52,-13],[-188,647],[-65,24],[-217,-331],[-215,173],[-150,34],[-80,-83],[-163,18],[-164,-252],[-141,-14],[-337,305],[-131,-145],[-142,10],[-104,223],[-279,221],[-298,-70],[-72,-128],[-39,-340],[-80,-238],[-19,-527]],[[50598,57634],[6,405],[-320,134],[-9,286],[-156,386],[-37,269],[22,286]],[[51185,61897],[392,263],[804,1161],[952,1126]],[[53333,64447],[439,-255],[156,-324],[197,220]],[[53939,57955],[110,-235],[-31,-107],[-14,-196],[-234,-457],[-74,-377],[-39,-307],[-59,-132],[-56,-414],[-148,-243],[-43,-299],[-63,-238],[-26,-246],[-191,-199],[-156,243],[-105,-10],[-165,-345],[-81,-6],[-132,-570],[-71,-418]],[[52361,53399],[-289,-213],[-105,31],[-107,-132],[-222,13],[-149,370],[-91,427],[-197,389],[-209,-7],[-245,1]],[[54244,54965],[-140,-599],[-67,-107],[-21,-458],[28,-249],[-23,-176],[132,-309],[23,-212],[103,-305],[127,-190],[12,-269],[29,-172]],[[54447,51919],[-20,-319],[-220,140],[-225,156],[-350,23]],[[53632,51919],[-35,32],[-164,-76],[-169,79],[-132,-38]],[[53132,51916],[-452,13]],[[52680,51929],[40,466],[-108,391],[-127,100],[-56,265],[-72,85],[4,163]],[[50518,54209],[-224,-126]],[[50294,54083],[-62,207],[-74,375],[-22,294],[61,532],[-69,215],[-27,466],[1,429],[-116,305],[20,184]],[[50006,57090],[243,-13]],[[50294,54083],[-436,-346],[-154,-203],[-250,-171],[-248,168]],[[49206,53531],[13,233],[-121,509],[73,667],[117,496],[-74,841]],[[49214,56277],[-38,444],[7,336],[482,27],[123,-43],[90,96],[128,-47]],[[48498,56707],[125,-129],[49,-195],[125,-125],[97,149],[130,22],[190,-152]],[[49206,53531],[-126,-7],[-194,116],[-178,-7],[-329,-103],[-193,-170],[-275,-217],[-54,15]],[[47857,53158],[22,487],[26,74],[-8,233],[-118,247],[-88,40],[-81,162],[60,262],[-28,286],[13,172]],[[47655,55121],[44,0],[17,258],[-22,114],[27,82],[103,71],[-69,473],[-64,245],[23,200],[55,46]],[[47655,55121],[-78,15],[-57,-238],[-78,3],[-55,126],[19,237],[-116,362],[-73,-67],[-59,-13]],[[47158,55546],[-77,-34],[3,217],[-44,155],[9,171],[-60,249],[-78,211],[-222,1],[-65,-112],[-76,-13],[-48,-128],[-32,-163],[-148,-260]],[[46320,55840],[-122,349],[-108,232],[-71,76],[-69,118],[-32,261],[-41,130],[-80,97]],[[45797,57103],[123,288],[84,-11],[73,99],[61,1],[44,78],[-24,196],[31,62],[5,200]],[[45797,57103],[-149,247],[-117,39],[-63,166],[1,90],[-84,125],[-18,127]],[[47857,53158],[-73,-5],[-286,282],[-252,449],[-237,324],[-187,381]],[[46822,54589],[66,189],[15,172],[126,320],[129,276]],[[46822,54589],[-75,44],[-200,238],[-144,316],[-49,216],[-34,437]],[[55125,52650],[-178,33],[-188,99],[-166,-313],[-146,-550]],[[56824,55442],[152,-239],[2,-192],[187,-308],[116,-255],[70,-355],[208,-234],[44,-187]],[[53609,47755],[-104,203],[-84,-100],[-112,-255]],[[53309,47603],[-228,626]],[[53081,48229],[212,326],[-105,391],[95,148],[187,73],[23,261],[148,-283],[245,-25],[85,279],[36,393],[-31,461],[-131,350],[120,684],[-69,117],[-207,-48],[-78,305],[21,258]],[[53081,48229],[-285,596],[-184,488],[-169,610],[9,196],[61,189],[67,430],[56,438]],[[52636,51176],[94,35],[404,-6],[-2,711]],[[52636,51176],[-52,90],[96,663]],[[59099,45126],[131,-264],[71,-501],[-47,-160],[-56,-479],[53,-490],[-87,-205],[-85,-549],[147,-153]],[[59226,42325],[-843,-487],[26,-421]],[[56448,40227],[-181,369],[-188,483],[13,1880],[579,-7],[-24,203],[41,222],[-49,277],[32,286],[-29,184]],[[59599,43773],[-77,-449],[77,-768],[97,9],[100,-191],[116,-427],[24,-760],[-120,-124],[-85,-410],[-181,365],[-21,417],[59,274],[-16,237],[-110,149],[-77,-54],[-159,284]],[[61198,44484],[45,-265],[-11,-588],[34,-519],[11,-923],[49,-290],[-83,-422],[-108,-410],[-177,-366],[-254,-225],[-313,-287],[-313,-634],[-107,-108],[-194,-420],[-115,-136],[-23,-421],[132,-448],[54,-346],[4,-177],[49,29],[-8,-579],[-45,-275],[65,-101],[-41,-245],[-116,-211],[-229,-199],[-334,-320],[-122,-219],[24,-248],[71,-40],[-24,-311]],[[58908,34785],[-24,261],[-41,265]],[[53383,47159],[-74,444]],[[53259,40357],[-26,372],[38,519],[96,541],[15,254],[90,532],[66,243],[159,386],[90,263],[29,438],[-15,335],[-83,211],[-74,358],[-68,355],[15,122],[85,235],[-84,570],[-57,396],[-139,374],[26,115]],[[58062,48902],[169,-46],[85,336],[147,-38]],[[59922,69905],[-49,-186]],[[59873,69719],[-100,82],[-58,-394],[69,-66],[-71,-81],[-12,-156],[131,80]],[[59832,69184],[7,-230],[-139,-944]],[[59700,68010],[-27,153],[-155,862]],[[59518,69025],[80,194],[-19,34],[74,276],[56,446],[40,149],[8,6]],[[59757,70130],[93,-1],[25,104],[75,8]],[[59950,70241],[4,-242],[-38,-90],[6,-4]],[[59757,70130],[99,482],[138,416],[5,21]],[[59999,71049],[125,-31],[45,-231],[-151,-223],[-68,-323]],[[63761,43212],[74,-251],[69,-390],[45,-711],[72,-276],[-28,-284],[-49,-174],[-94,347],[-53,-175],[53,-438],[-24,-250],[-77,-137],[-18,-500],[-109,-689],[-137,-814],[-172,-1120],[-106,-821],[-125,-685],[-226,-140],[-243,-250],[-160,151],[-220,211],[-77,312],[-18,524],[-98,471],[-26,425],[50,426],[128,102],[1,197],[133,447],[25,377],[-65,280],[-52,372],[-23,544],[97,331],[38,375],[138,22],[155,121],[103,107],[122,7],[158,337],[229,364],[83,297],[-38,253],[118,-71],[153,410],[6,356],[92,264],[96,-254]],[[59873,69719],[0,-362],[-41,-173]],[[45321,58350],[36,262]],[[52633,68486],[-118,1061],[-171,238],[-3,143],[-227,352],[-24,445],[171,330],[65,487],[-44,563],[57,303]],[[52339,72408],[302,239],[195,-71],[-9,-299],[236,217],[20,-113],[-139,-290],[-2,-273],[96,-147],[-36,-511],[-183,-297],[53,-322],[143,-10],[70,-281],[106,-92]],[[53191,70158],[-16,-454],[-135,-170],[-86,-189],[-191,-228],[30,-244],[-24,-250],[-136,-137]],[[47592,66920],[-2,700],[449,436],[277,90],[227,159],[107,295],[324,234],[12,438],[161,51],[126,219],[363,99],[51,230],[-73,125],[-96,624],[-17,359],[-104,379]],[[49397,71358],[267,323],[300,102],[175,244],[268,180],[471,105],[459,48],[140,-87],[262,232],[297,5],[113,-137],[190,35]],[[52633,68486],[90,-522],[15,-274],[-49,-482],[21,-270],[-36,-323],[24,-371],[-110,-247],[164,-431],[11,-253],[99,-330],[130,109],[219,-275],[122,-370]],[[59922,69905],[309,-234],[544,630]],[[60775,70301],[112,-720]],[[60887,69581],[-53,-89],[-556,-296],[277,-591],[-92,-101],[-46,-197],[-212,-82],[-66,-213],[-120,-182],[-310,94]],[[59709,67924],[-9,86]],[[64327,64904],[49,29],[11,-162],[217,93],[230,-15],[168,-18],[190,400],[207,379],[176,364]],[[65575,65974],[52,-202]],[[65627,65772],[38,-466]],[[65665,65306],[-142,-3],[-23,-384],[50,-82],[-126,-117],[-1,-241],[-81,-245],[-7,-238]],[[65335,63996],[-56,-125],[-835,298],[-106,599],[-11,136]],[[64113,65205],[-18,430],[75,310],[76,64],[84,-185],[5,-346],[-61,-348]],[[64274,65130],[-77,-42],[-84,117]],[[63326,68290],[58,-261],[-25,-135],[89,-445]],[[63448,67449],[-196,-16],[-69,282],[-248,57]],[[62935,67772],[204,567],[187,-49]],[[60775,70301],[615,614],[105,715],[-26,431],[152,146],[142,369]],[[61763,72576],[119,92],[324,-77],[97,-150],[133,100]],[[62436,72541],[180,-705],[182,-177],[21,-345],[-139,-204],[-65,-461],[193,-562],[340,-324],[143,-449],[-46,-428],[89,0],[3,-314],[153,-311]],[[63490,68261],[-164,29]],[[62935,67772],[-516,47],[-784,1188],[-413,414],[-335,160]],[[65665,65306],[125,-404],[155,-214],[203,-78],[165,-107],[125,-339],[75,-196],[100,-75],[-1,-132],[-101,-352],[-44,-166],[-117,-189],[-104,-404],[-126,31],[-58,-141],[-44,-300],[34,-395],[-26,-72],[-128,2],[-174,-221],[-27,-288],[-63,-125],[-173,5],[-109,-149],[1,-238],[-134,-165],[-153,56],[-186,-199],[-128,-34]],[[64752,60417],[-91,413],[-217,975]],[[64444,61805],[833,591],[185,1182],[-127,418]],[[65575,65974],[80,201],[35,-51],[-26,-244],[-37,-108]],[[96448,41190],[175,-339],[-92,-78],[-93,259],[10,158]],[[96330,41322],[-39,163],[-6,453],[133,-182],[45,-476],[-75,74],[-58,-32]],[[78495,57780],[-66,713],[178,492],[359,112],[261,-84]],[[79227,59013],[229,-232],[126,407],[246,-217]],[[79828,58971],[64,-394],[-34,-708],[-467,-455],[122,-358],[-292,-43],[-240,-238]],[[78981,56775],[-233,87],[-112,307],[-141,611]],[[78495,57780],[-249,271],[-238,-11],[41,464],[-245,-3],[-22,-650],[-150,-863],[-90,-522],[19,-428],[181,-18],[113,-539],[50,-512],[155,-338],[168,-69],[144,-306]],[[78372,54256],[-91,-243],[-183,-71],[-22,304],[-227,258],[-48,-105]],[[77801,54399],[-110,227],[-47,292],[-148,334],[-135,280],[-45,-347],[-53,328],[30,369],[82,566]],[[77375,56448],[135,607],[152,551],[-108,539],[4,274],[-32,330],[-185,470],[-66,296],[96,109],[101,514],[-113,390],[-177,431],[-134,519],[117,107],[127,639],[196,26],[162,256],[159,137]],[[77809,62643],[120,-182],[16,-355],[188,-27],[-68,-623],[6,-530],[293,353],[83,-104],[163,17],[56,205],[210,-40],[211,-480],[18,-583],[224,-515],[-12,-500],[-90,-266]],[[77809,62643],[59,218],[237,384]],[[78105,63245],[25,-139],[148,-16],[-42,676],[144,86]],[[78380,63852],[162,-466],[125,-537],[342,-5],[108,-515],[-178,-155],[-80,-212],[333,-353],[231,-699],[175,-520],[210,-411],[70,-418],[-50,-590]],[[77375,56448],[-27,439],[86,452],[-94,350],[23,644],[-113,306],[-90,707],[-50,746],[-121,490],[-183,-297],[-315,-421],[-156,53],[-172,138],[96,732],[-58,554],[-218,681],[34,213],[-163,76],[-197,481]],[[75657,62792],[-18,476],[97,-90],[6,424]],[[75742,63602],[137,140],[-30,251],[63,201],[11,612],[217,-135],[124,487],[14,288],[153,496],[-8,338],[359,408],[199,-107],[-23,364],[97,108],[-20,224]],[[77035,67277],[162,44],[93,-348],[121,-141],[8,-452],[-11,-487],[-263,-493],[-33,-701],[293,98],[66,-544],[176,-115],[-81,-490],[206,-222],[121,-109],[203,172],[9,-244]],[[78380,63852],[149,145],[221,-3],[271,68],[236,315],[134,-222],[254,-108],[-44,-340],[132,-240],[280,-154]],[[80013,63313],[-371,-505],[-231,-558],[-61,-410],[212,-623],[260,-772],[252,-365],[169,-475],[127,-1093],[-37,-1039],[-232,-389],[-318,-381],[-227,-492],[-346,-550],[-101,378],[78,401],[-206,335]],[[86327,75524],[0,0]],[[86327,75524],[-106,36],[-120,-200],[-83,-202],[10,-424],[-143,-130],[-50,-105],[-104,-174],[-185,-97],[-121,-159],[-9,-256],[-32,-65],[111,-96],[157,-259]],[[85652,73393],[-40,-143],[-118,-39],[-197,-29],[-108,-266],[-124,21],[-17,-54]],[[85048,72883],[-135,112],[-34,-111],[-81,-49],[-10,112],[-72,54],[-75,94],[76,260],[66,69],[-25,108],[71,319],[-18,96],[-163,65],[-131,158]],[[84517,74170],[227,379],[306,318],[191,419],[131,-185],[241,-22],[-44,312],[429,254],[111,331],[179,-348]],[[85652,73393],[240,-697],[68,-383],[3,-681],[-105,-325],[-252,-113],[-222,-245],[-250,-51],[-31,322],[51,443],[-122,615],[206,99],[-190,506]],[[82410,80055],[-135,-446],[-197,-590],[72,-241],[157,74],[274,-92],[214,219],[223,-189],[251,-413],[-30,-210],[-219,66],[-404,-78],[-195,-168],[-204,-391],[-423,-229],[-277,-313],[-286,120],[-156,53],[-146,-381],[89,-227],[45,-195],[-194,-199],[-200,-316],[-324,-208],[-417,-22],[-448,-205],[-324,-318],[-123,184],[-336,-1],[-411,359],[-274,88],[-369,-82],[-574,133],[-306,-14],[-163,351],[-127,544],[-171,66],[-336,368],[-374,83],[-330,101],[-100,256],[107,690],[-192,476],[-396,222],[-233,313],[-73,413]],[[75742,63602],[-147,937],[-76,-2],[-46,-377],[-152,306],[86,336],[124,34],[128,500],[-160,101],[-257,-8],[-265,81],[-24,410],[-133,30],[-220,255],[-98,-401],[200,-313],[-173,-220],[-62,-215],[171,-159],[-47,-356],[96,-444],[43,-486]],[[74730,63611],[-39,-216],[-189,7],[-343,-122],[16,-445],[-148,-349],[-400,-398],[-311,-695],[-209,-373],[-276,-387],[-1,-271],[-138,-146],[-251,-212],[-129,-31],[-84,-450],[58,-769],[15,-490],[-118,-561],[-1,-1004],[-144,-29],[-126,-450],[84,-195],[-253,-168],[-93,-401],[-112,-170],[-263,552],[-128,827],[-107,596],[-97,279],[-148,568],[-69,739],[-48,369],[-253,811],[-115,1145],[-83,756],[1,716],[-54,553],[-404,-353],[-196,70],[-362,716],[133,214],[-82,232],[-326,501]],[[68937,64577],[185,395],[612,-2],[-56,507],[-156,300],[-31,455],[-182,265],[306,619],[323,-45],[290,620],[174,599],[270,593],[-4,421],[236,342],[-224,292],[-96,400],[-99,517],[137,255],[421,-144],[310,88],[268,496]],[[71621,71550],[298,-692],[-28,-482],[111,-303],[-9,-301],[-200,79],[78,-651],[273,-374],[386,-413]],[[72530,68413],[-176,-268],[-108,-553],[269,-224],[262,-289],[362,-332],[381,-76],[160,-301],[215,-56],[334,-138],[231,10],[32,234],[-36,375],[21,255]],[[74477,67050],[170,124],[23,-465]],[[74670,66709],[6,-119],[252,-224],[175,92],[234,-39],[227,17],[20,363],[-113,189]],[[75471,66988],[224,74],[252,439],[321,376],[233,-145],[198,249],[130,-367],[-94,-248],[300,-89]],[[75657,62792],[-79,308],[-16,301],[-53,285],[-116,344],[-256,23],[25,-243],[-87,-329],[-118,120],[-41,-108],[-78,65],[-108,53]],[[74670,66709],[184,439],[150,150],[198,-137],[147,-14],[122,-159]],[[72530,68413],[115,141],[223,-182],[280,-385],[157,-84],[93,-284],[216,-117],[225,-259],[314,-136],[324,-57]],[[68937,64577],[-203,150],[-83,424],[-215,450],[-512,-111],[-451,-11],[-391,-83]],[[67082,65396],[105,687],[400,305],[-23,272],[-133,96],[-7,520],[-266,260],[-112,357],[-137,310]],[[66909,68203],[465,-301],[278,88],[166,-75],[56,129],[194,-52],[361,246],[10,503],[154,334],[207,-1],[31,166],[212,77],[103,-55],[108,166],[-15,355],[118,356],[177,150],[-110,390],[265,-18],[76,213],[-12,227],[139,248],[-32,294],[-66,250],[163,258],[298,124],[319,68],[141,109],[162,67]],[[70877,72519],[205,-276],[82,-454],[457,-239]],[[68841,72526],[85,-72],[201,189],[93,-114],[90,271],[166,-12],[43,86],[29,239],[120,205],[150,-134],[-30,-181],[84,-28],[-26,-496],[110,-194],[97,125],[123,58],[173,265],[192,-44],[286,-1]],[[70827,72688],[50,-169]],[[66909,68203],[252,536],[-23,380],[-210,100],[-22,375],[-91,472],[119,323],[-121,87],[76,430],[113,736]],[[67002,71642],[284,-224],[209,79],[58,268],[219,89],[157,180],[55,472],[234,114],[44,211],[131,-158],[84,-19]],[[69725,74357],[-101,-182],[-303,98],[-26,-340],[301,46],[343,-192],[526,89]],[[70465,73876],[70,-546],[91,59],[169,-134],[-10,-230],[42,-337]],[[72294,75601],[-39,-134],[-438,-320],[-99,-234],[-356,-70],[-105,-378],[-294,80],[-192,-116],[-266,-279],[39,-138],[-79,-136]],[[67002,71642],[-24,498],[-207,21],[-318,523],[-221,65],[-308,299],[-197,55],[-122,-110],[-186,17],[-197,-338],[-244,-114]],[[64978,72558],[-52,417],[40,618],[-216,200],[71,405],[-184,34],[61,498],[262,-145],[244,189],[-202,355],[-80,338],[-224,-151],[-28,-433],[-87,383]],[[62436,72541],[-152,473],[55,183],[-87,678],[190,168]],[[62442,74043],[44,-223],[141,-273],[190,-78]],[[62817,73469],[101,17]],[[62918,73486],[327,436],[104,44],[82,-174],[-95,-292],[173,-309],[69,29]],[[63578,73220],[88,-436],[263,-123],[193,-296],[395,-102],[434,156],[27,139]],[[67082,65396],[-523,179],[-303,136],[-313,76],[-118,725],[-133,105],[-214,-106],[-280,-286],[-339,196],[-281,454],[-267,168],[-186,561],[-205,788],[-149,-96],[-177,196],[-104,-231]],[[59999,71049],[-26,452],[68,243]],[[60041,71744],[74,129],[75,130],[15,329],[91,-115],[306,165],[147,-112],[229,2],[320,222],[149,-10],[316,92]],[[62817,73469],[-113,342],[1,91],[-123,-2],[-82,159],[-58,-16]],[[62442,74043],[-109,172],[-207,147],[27,288],[-47,208]],[[62106,74858],[386,92]],[[62492,74950],[57,-155],[106,-103],[-56,-148],[148,-202],[-78,-189],[118,-160],[124,-97],[7,-410]],[[55734,91409],[371,-289],[433,-402],[8,-910],[93,-230]],[[56639,89578],[-478,-167],[-269,-413],[43,-361],[-441,-475],[-537,-509],[-202,-832],[198,-416],[265,-328],[-255,-666],[-289,-138],[-106,-992],[-157,-554],[-337,57],[-158,-468],[-321,-27],[-89,558],[-232,671],[-211,835]],[[58829,81362],[-239,-35],[-85,-129],[-18,-298],[-111,57],[-250,-28],[-73,138],[-104,-103],[-105,86],[-218,12],[-310,141],[-281,47],[-215,-14],[-152,-160],[-133,-23]],[[56535,81053],[-6,263],[-85,274],[166,121],[2,235],[-77,225],[-12,261]],[[56523,82432],[268,-4],[302,223],[64,333],[228,190],[-26,264]],[[57359,83438],[169,100],[298,228]],[[60617,78409],[-222,-48],[-185,-191],[-260,-31],[-239,-220],[14,-317]],[[59287,77741],[-38,64],[-432,149],[-19,221],[-257,-73],[-103,-325],[-215,-437]],[[58223,77340],[-126,101],[-131,-95],[-124,109]],[[57842,77455],[70,64],[49,203],[76,188],[-20,106],[58,47],[27,-81],[164,-18],[74,44],[-52,60],[19,88],[-97,150],[-40,247],[-101,97],[20,200],[-125,159],[-115,22],[-204,184],[-185,-58],[-66,-87]],[[57394,79070],[-118,0],[-69,-139],[-205,-56],[-95,-91],[-129,144],[-178,3],[-172,65],[-120,-127]],[[56308,78869],[-19,159],[-155,161]],[[56134,79189],[55,238],[77,154]],[[56266,79581],[60,-35],[-71,266],[252,491],[138,69],[29,166],[-139,515]],[[56266,79581],[-264,227],[-200,-84],[-131,61],[-165,-127],[-140,210],[-114,-81],[-16,36]],[[55236,79823],[-127,291],[-207,36],[-26,185],[-191,66],[-41,-153],[-151,122],[17,163],[-207,51],[-132,191]],[[54171,80775],[-114,377],[22,204],[-69,316],[-101,210],[77,158],[-64,300]],[[53922,82340],[189,174],[434,273],[350,200],[277,-100],[21,-144],[268,-7]],[[56314,82678],[142,-64],[67,-182]],[[54716,79012],[-21,-241],[-156,-2],[53,-128],[-92,-380]],[[54500,78261],[-53,-100],[-243,-14],[-140,-134],[-229,45]],[[53835,78058],[-398,153],[-62,205],[-274,-102],[-32,-113],[-169,84]],[[52900,78285],[-142,16],[-125,108],[42,145],[-10,104]],[[52665,78658],[83,33],[141,-164],[39,156],[245,-25],[199,106],[133,-18],[87,-121],[26,100],[-40,385],[100,75],[98,272]],[[53776,79457],[206,-190],[157,242],[98,44],[215,-180],[131,30],[128,-111]],[[54711,79292],[-23,-75],[28,-205]],[[56308,78869],[-170,-123],[-131,-401],[-168,-401],[-223,-111]],[[55616,77833],[-173,26],[-213,-155]],[[55230,77704],[-104,-89],[-229,114],[-208,253],[-88,73]],[[54601,78055],[-54,200],[-47,6]],[[54716,79012],[141,-151],[103,-65],[233,73],[22,118],[111,18],[135,92],[30,-38],[130,74],[66,139],[91,36],[297,-180],[59,61]],[[57842,77455],[-50,270],[30,252],[-9,259],[-160,352],[-89,249],[-86,175],[-84,58]],[[58223,77340],[6,-152],[-135,-128],[-84,56],[-78,-713]],[[57932,76403],[-163,62],[-202,215],[-327,-138],[-138,-150],[-408,31],[-213,92],[-108,-43],[-80,243]],[[56293,76715],[-51,103],[65,99],[-69,74],[-87,-133],[-162,172],[-22,244],[-169,139],[-31,188],[-151,232]],[[55907,83187],[-59,497]],[[55848,83684],[318,181],[466,-38],[273,59],[39,-123],[148,-38],[267,-287]],[[55848,83684],[10,445],[136,371],[262,202],[221,-442],[223,12],[53,453]],[[56753,84725],[237,105],[121,-73],[239,-219],[229,-1]],[[56753,84725],[32,349],[-102,-75],[-176,210],[-24,340],[351,164],[350,86],[301,-97],[287,17]],[[54171,80775],[-124,-62],[-73,68],[-70,-113],[-200,-114],[-103,-147],[-202,-129],[49,-176],[30,-249],[141,-142],[157,-254]],[[52665,78658],[-298,181],[-57,-128],[-236,4]],[[51718,79804],[16,259],[-56,133]],[[51678,80196],[32,400]],[[51710,80596],[-47,619],[167,0],[70,222],[69,541],[-51,200]],[[51918,82178],[54,125],[232,32],[52,-130],[188,291],[-63,222],[-13,335]],[[52368,83053],[210,-78],[178,90]],[[52756,83065],[4,-228],[281,-138],[-3,-210],[283,111],[156,162],[313,-233],[132,-189]],[[57932,76403],[-144,-245],[-101,-422],[89,-337]],[[57776,75399],[-239,79],[-283,-186]],[[57254,75292],[-3,-294],[-252,-56],[-196,206],[-222,-162],[-206,17]],[[56375,75003],[-20,391],[-139,189]],[[56216,75583],[46,84],[-30,70],[47,188],[105,185],[-135,255],[-24,216],[68,134]],[[57302,71436],[-35,-175],[-400,-50],[3,98],[-339,115],[52,251],[152,-199],[216,34],[207,-42],[-7,-103],[151,71]],[[57254,75292],[135,-157],[-86,-369],[-66,-67]],[[57237,74699],[-169,17],[-145,56],[-336,-154],[192,-332],[-141,-96],[-154,-1],[-147,305],[-52,-130],[62,-353],[139,-277],[-105,-129],[155,-273],[137,-171],[4,-334],[-257,157],[82,-302],[-176,-62],[105,-521],[-184,-8],[-228,257],[-104,473],[-49,393],[-108,272],[-143,337],[-18,168]],[[55597,73991],[129,287],[16,192],[91,85],[5,155]],[[55838,74710],[182,53],[106,129],[150,-12],[46,103],[53,20]],[[60041,71744],[-102,268],[105,222],[-169,-51],[-233,136],[-191,-340],[-421,-66],[-225,317],[-300,20],[-64,-245],[-192,-70],[-268,314],[-303,-11],[-165,588],[-203,328],[135,459],[-176,283],[308,565],[428,23],[117,449],[529,-78],[334,383],[324,167],[459,13],[485,-417],[399,-228],[323,91],[239,-53],[328,309]],[[61542,75120],[296,28],[268,-290]],[[57776,75399],[33,-228],[243,-190],[-51,-145],[-330,-33],[-118,-182],[-232,-319],[-87,276],[3,121]],[[55597,73991],[-48,41],[-5,130],[-154,199],[-24,281],[23,403],[38,184],[-47,93]],[[55380,75322],[-18,188],[120,291],[18,-111],[75,52]],[[55575,75742],[59,-159],[66,-60],[19,-214]],[[55719,75309],[-35,-201],[39,-254],[115,-144]],[[55230,77704],[67,-229],[89,-169],[-107,-222]],[[55279,77084],[-126,131],[-192,-8],[-239,98],[-130,-13],[-60,-123],[-99,136],[-59,-245],[136,-277],[61,-183],[127,-221],[106,-130],[105,-247],[246,-224]],[[55155,75778],[-31,-100]],[[55124,75678],[-261,218],[-161,213],[-254,176],[-233,434],[56,45],[-127,248],[-5,200],[-179,93],[-85,-255],[-82,198],[6,205],[10,9]],[[53809,77462],[194,-20],[51,100],[94,-97],[109,-11],[-1,165],[97,60],[27,239],[221,157]],[[52900,78285],[-22,-242],[-122,-100],[-206,75],[-60,-239],[-132,-19],[-48,94],[-156,-200],[-134,-28],[-120,126]],[[51576,79843],[30,331],[72,22]],[[50698,80799],[222,117]],[[50920,80916],[204,-47],[257,123],[176,-258],[153,-138]],[[50920,80916],[143,162],[244,869],[380,248],[231,-17]],[[47490,75324],[101,150],[113,86],[70,-289],[164,0],[47,75],[162,-21],[78,-296],[-129,-160],[-3,-461],[-45,-86],[-11,-280],[-120,-48],[111,-355],[-77,-388],[96,-175],[-38,-161],[-103,-222],[23,-195]],[[47929,72498],[-112,-153],[-146,83],[-143,-65],[42,462],[-26,363],[-124,55],[-67,224],[22,386],[111,215],[20,239],[58,355],[-6,250],[-56,212],[-12,200]],[[47490,75324],[14,420],[-114,257],[393,426],[340,-106],[373,3],[296,-101],[230,31],[449,-19]],[[50829,75674],[15,-344],[-263,-393],[-356,-125],[-25,-199],[-171,-327],[-107,-481],[108,-338],[-160,-263],[-60,-384],[-210,-118],[-197,-454],[-352,-9],[-265,11],[-174,-209],[-106,-223],[-136,49],[-103,199],[-79,340],[-259,92]],[[48278,82406],[46,-422],[-210,-528],[-493,-349],[-393,89],[225,617],[-145,601],[378,463],[210,276]],[[47896,83153],[57,-317],[-57,-317],[172,9],[210,-122]],[[96049,38125],[228,-366],[144,-272],[-105,-142],[-153,160],[-199,266],[-179,313],[-184,416],[-38,201],[119,-9],[156,-201],[122,-200],[89,-166]],[[95032,44386],[78,-203],[-194,4],[-106,363],[166,-142],[56,-22]],[[94910,44908],[-42,-109],[-206,512],[-57,353],[94,0],[100,-473],[111,-283]],[[94680,44747],[-108,-14],[-170,60],[-58,91],[17,235],[183,-93],[91,-124],[45,-155]],[[94344,45841],[65,-187],[12,-119],[-218,251],[-152,212],[-104,197],[41,60],[128,-142],[228,-272]],[[93649,46431],[111,-193],[-56,-33],[-121,134],[-114,243],[14,99],[166,-250]],[[99134,26908],[-105,-319],[-138,-404],[-214,-236],[-48,155],[-116,85],[160,486],[-91,326],[-299,236],[8,214],[201,206],[47,455],[-13,382],[-113,396],[8,104],[-133,244],[-218,523],[-117,418],[104,46],[151,-328],[216,-153],[78,-526],[202,-622],[5,403],[126,-161],[41,-447],[224,-192],[188,-48],[158,226],[141,-69],[-67,-524],[-85,-345],[-212,12],[-74,-179],[26,-254],[-41,-110]],[[97129,24846],[238,310],[167,306],[123,441],[106,149],[41,330],[195,273],[61,-251],[63,-244],[198,239],[80,-249],[0,-249],[-103,-274],[-182,-435],[-142,-238],[103,-284],[-214,-7],[-238,-223],[-75,-387],[-157,-597],[-219,-264],[-138,-169],[-256,13],[-180,194],[-302,42],[-46,217],[149,438],[349,583],[179,111],[200,225]],[[91024,26469],[166,-39],[20,-702],[-95,-203],[-29,-476],[-97,162],[-193,-412],[-57,32],[-171,19],[-171,505],[-38,390],[-160,515],[7,271],[181,-52],[269,-204],[151,81],[217,113]],[[85040,31546],[-294,-303],[-241,-137],[-53,-309],[-103,-240],[-236,-15],[-174,-52],[-246,107],[-199,-64],[-191,-27],[-165,-315],[-81,26],[-140,-167],[-133,-187],[-203,23],[-186,0],[-295,377],[-149,113],[6,338],[138,81],[47,134],[-10,212],[34,411],[-31,350],[-147,598],[-45,337],[12,336],[-111,385],[-7,174],[-123,235],[-35,463],[-158,467],[-39,252],[122,-255],[-93,548],[137,-171],[83,-229],[-5,303],[-138,465],[-26,186],[-65,177],[31,341],[56,146],[38,295],[-29,346],[114,425],[21,-450],[118,406],[225,198],[136,252],[212,217],[126,46],[77,-73],[219,220],[168,66],[42,129],[74,54],[153,-14],[292,173],[151,262],[71,316],[163,300],[13,236],[7,321],[194,502],[117,-510],[119,118],[-99,279],[87,287],[122,-128],[34,449],[152,291],[67,233],[140,101],[4,165],[122,-69],[5,148],[122,85],[134,80],[205,-271],[155,-350],[173,-4],[177,-56],[-59,325],[133,473],[126,155],[-44,147],[121,338],[168,208],[142,-70],[234,111],[-5,302],[-204,195],[148,86],[184,-147],[148,-242],[234,-151],[79,60],[172,-182],[162,169],[105,-51],[65,113],[127,-292],[-74,-316],[-105,-239],[-96,-20],[32,-236],[-81,-295],[-99,-291],[20,-166],[221,-327],[214,-189],[143,-204],[201,-350],[78,1],[145,-151],[43,-183],[265,-200],[183,202],[55,317],[56,262],[34,324],[85,470],[-39,286],[20,171],[-32,339],[37,445],[53,120],[-43,197],[67,313],[52,325],[7,168],[104,222],[78,-289],[19,-371],[70,-71],[11,-249],[101,-300],[21,-335],[-10,-214],[100,-464],[179,223],[92,-250],[133,-231],[-29,-262],[60,-506],[42,-295],[70,-72],[75,-505],[-27,-307],[90,-400],[301,-309],[197,-281],[186,-257],[-37,-143],[159,-371],[108,-639],[111,130],[113,-256],[68,91],[48,-626],[197,-363],[129,-226],[217,-478],[78,-475],[7,-337],[-19,-365],[132,-502],[-16,-523],[-48,-274],[-75,-527],[6,-339],[-55,-423],[-123,-538],[-205,-290],[-102,-458],[-93,-292],[-82,-510],[-107,-294],[-70,-442],[-36,-407],[14,-187],[-159,-205],[-311,-22],[-257,-242],[-127,-229],[-168,-254],[-230,262],[-170,104],[43,308],[-152,-112],[-243,-428],[-240,160],[-158,94],[-159,42],[-269,171],[-179,364],[-52,449],[-64,298],[-137,240],[-267,71],[91,287],[-67,438],[-136,-408],[-247,-109],[146,327],[42,341],[107,289],[-22,438],[-226,-504],[-174,-202],[-106,-470],[-217,243],[9,313],[-174,429],[-147,221],[52,137],[-356,358],[-195,17],[-267,287],[-498,-56],[-359,-211],[-317,-197],[-265,39]],[[72718,55024],[-42,-615],[-116,-168],[-242,-135],[-132,470],[-49,849],[126,959],[192,-328],[129,-416],[134,-616]],[[80409,61331],[-228,183],[-8,509],[137,267],[304,166],[159,-14],[62,-226],[-122,-260],[-64,-341],[-240,-284]],[[84517,74170],[-388,-171],[-204,-277],[-300,-161],[148,274],[-58,230],[220,397],[-147,310],[-242,-209],[-314,-411],[-171,-381],[-272,-29],[-142,-275],[147,-400],[227,-97],[9,-265],[220,-173],[311,422],[247,-230],[179,-15],[45,-310],[-393,-165],[-130,-319],[-270,-296],[-142,-414],[299,-325],[109,-581],[169,-541],[189,-454],[-5,-439],[-174,-161],[66,-315],[164,-184],[-43,-481],[-71,-468],[-155,-53],[-203,-640],[-225,-775],[-258,-705],[-382,-545],[-386,-498],[-313,-68],[-170,-262],[-96,192],[-157,-294],[-388,-296],[-294,-90],[-95,-624],[-154,-35],[-73,429],[66,228],[-373,189],[-131,-96]],[[83826,64992],[-167,-947],[-119,-485],[-146,499],[-32,438],[163,581],[223,447],[127,-176],[-49,-357]],[[53835,78058],[-31,-291],[67,-251]],[[53871,77516],[-221,86],[-226,-210],[15,-293],[-34,-168],[91,-301],[261,-298],[140,-488],[309,-476],[217,3],[68,-130],[-78,-118],[249,-214],[204,-178],[238,-308],[29,-111],[-52,-211],[-154,276],[-242,97],[-116,-382],[200,-219],[-33,-309],[-116,-35],[-148,-506],[-116,-46],[1,181],[57,317],[60,126],[-108,342],[-85,298],[-115,74],[-82,255],[-179,107],[-120,238],[-206,38],[-217,267],[-254,384],[-189,340],[-86,585],[-138,68],[-226,195],[-128,-80],[-161,-274],[-115,-43]],[[54100,73116],[211,51],[-100,-465],[41,-183],[-58,-303],[-213,222],[-141,64],[-387,300],[38,304],[325,-54],[284,64]],[[52419,74744],[139,183],[166,-419],[-39,-782],[-126,38],[-113,-197],[-105,156],[-11,713],[-64,338],[153,-30]],[[52368,83053],[-113,328],[-8,604],[46,159],[80,177],[244,37],[98,163],[223,167],[-9,-304],[-82,-192],[33,-166],[151,-89],[-68,-223],[-83,64],[-200,-425],[76,-288]],[[53436,83731],[88,-296],[-166,-478],[-291,333],[-39,246],[408,195]],[[47896,83153],[233,24],[298,-365],[-149,-406]],[[49140,82132],[1,0],[40,343],[-186,364],[-4,8],[-337,104],[-66,160],[101,264],[-92,163],[-149,-279],[-17,569],[-140,301],[101,611],[216,480],[222,-47],[335,49],[-297,-639],[283,81],[304,-3],[-72,-481],[-250,-530],[287,-38],[22,-62],[248,-697],[190,-95],[171,-673],[79,-233],[337,-113],[-34,-378],[-142,-173],[111,-305],[-250,-310],[-371,6],[-473,-163],[-130,116],[-183,-276],[-257,67],[-195,-226],[-148,118],[407,621],[249,127],[-2,1],[-434,98],[-79,235],[291,183],[-152,319],[52,387],[413,-54]],[[45969,89843],[-64,-382],[314,-403],[-361,-451],[-801,-405],[-240,-107],[-365,87],[-775,187],[273,261],[-605,289],[492,114],[-12,174],[-583,137],[188,385],[421,87],[433,-400],[422,321],[349,-167],[453,315],[461,-42]],[[63495,75281],[146,-311],[141,-419],[130,-28],[85,-159],[-228,-47],[-49,-459],[-48,-207],[-101,-138],[7,-293]],[[62492,74950],[68,96],[207,-169],[149,-36],[38,70],[-136,319],[72,82]],[[61542,75120],[42,252],[-70,403],[-160,218],[-154,68],[-102,181]],[[83564,58086],[-142,450],[238,-22],[97,-213],[-74,-510],[-119,295]],[[84051,56477],[70,165],[30,367],[153,35],[-44,-398],[205,570],[-26,-563],[-100,-195],[-87,-373],[-87,-175],[-171,409],[57,158]],[[85104,55551],[28,-392],[16,-332],[-94,-540],[-102,602],[-130,-300],[89,-435],[-79,-277],[-327,343],[-78,428],[84,280],[-176,280],[-87,-245],[-131,23],[-205,-330],[-46,173],[109,498],[175,166],[151,223],[98,-268],[212,162],[45,264],[196,15],[-16,457],[225,-280],[23,-297],[20,-218]],[[82917,56084],[-369,-561],[136,414],[200,364],[167,409],[146,587],[49,-482],[-183,-325],[-146,-406]],[[83982,61347],[-46,-245],[95,-423],[-73,-491],[-164,-196],[-43,-476],[62,-471],[147,-65],[123,70],[347,-328],[-27,-321],[91,-142],[-29,-272],[-216,290],[-103,310],[-71,-217],[-177,354],[-253,-87],[-138,130],[14,244],[87,151],[-83,136],[-36,-213],[-137,340],[-41,257],[-11,566],[112,-195],[29,925],[90,535],[169,-1],[171,-168],[85,153],[26,-150]],[[83899,57324],[-43,282],[166,-183],[177,1],[-5,-247],[-129,-251],[-176,-178],[-10,275],[20,301]],[[84861,57766],[78,-660],[-214,157],[5,-199],[68,-364],[-132,-133],[-11,416],[-84,31],[-43,357],[163,-47],[-4,224],[-169,451],[266,-13],[77,-220]],[[78372,54256],[64,-56],[164,-356],[116,-396],[16,-398],[-29,-269],[27,-203],[20,-349],[98,-163],[109,-523],[-5,-199],[-197,-40],[-263,438],[-329,469],[-32,301],[-161,395],[-38,489],[-100,322],[30,431],[-61,250]],[[80461,51765],[204,-202],[214,110],[56,500],[119,112],[333,128],[199,467],[137,374]],[[81723,53254],[126,-307],[58,202],[133,-19],[16,377],[13,291]],[[82069,53798],[214,411],[140,462],[112,2],[143,-299],[13,-257],[183,-165],[231,-177],[-20,-232],[-186,-29],[50,-289],[-205,-201]],[[81723,53254],[110,221],[236,323]],[[53809,77462],[62,54]],[[57797,86326],[-504,-47],[-489,-216],[-452,-125],[-161,323],[-269,193],[62,582],[-135,533],[133,345],[252,371],[635,640],[185,124],[-28,250],[-387,279]],[[54711,79292],[39,130],[123,-10],[95,61],[7,55],[54,28],[18,134],[64,26],[43,106],[82,1]],[[60669,61213],[161,-684],[77,-542],[152,-288],[379,-558],[154,-336],[151,-341],[87,-203],[136,-178]],[[61966,58083],[-83,-144],[-119,51]],[[61764,57990],[-95,191],[-114,346],[-124,190],[-71,204],[-242,237],[-191,7],[-67,124],[-163,-139],[-168,268],[-87,-441],[-323,124]],[[89411,73729],[-256,-595],[4,-610],[-104,-472],[48,-296],[-145,-416],[-355,-278],[-488,-36],[-396,-675],[-186,227],[-12,442],[-483,-130],[-329,-279],[-325,-11],[282,-435],[-186,-1004],[-179,-248],[-135,229],[69,533],[-176,172],[-113,405],[263,182],[145,371],[280,306],[203,403],[553,177],[297,-121],[291,1050],[185,-282],[408,591],[158,229],[174,723],[-47,664],[117,374],[295,108],[152,-819],[-9,-479]],[[90169,76553],[197,250],[62,-663],[-412,-162],[-244,-587],[-436,404],[-152,-646],[-308,-9],[-39,587],[138,455],[296,33],[81,817],[83,460],[326,-615],[213,-198],[195,-126]],[[86769,70351],[154,352],[158,-68],[114,248],[204,-127],[35,-203],[-156,-357],[-114,189],[-143,-137],[-73,-346],[-181,168],[2,281]],[[64752,60417],[-201,-158],[-54,-263],[-6,-201],[-277,-249],[-444,-276],[-249,-417],[-122,-33],[-83,35],[-163,-245],[-177,-114],[-233,-30],[-70,-34],[-61,-156],[-73,-43],[-43,-150],[-137,13],[-89,-80],[-192,30],[-72,345],[8,323],[-46,174],[-54,437],[-80,243],[56,29],[-29,270],[34,114],[-12,257]],[[61883,60238],[121,189],[-28,249],[74,290],[114,-153],[75,53],[321,14],[50,-59],[269,-60],[106,30],[70,-197],[130,99],[199,620],[259,266],[801,226]],[[63448,67449],[109,-510],[137,-135],[47,-207],[190,-249],[16,-243],[-27,-197],[35,-199],[80,-165],[37,-194],[41,-145]],[[64274,65130],[53,-226]],[[61883,60238],[-37,252],[-83,178],[-22,236],[-143,212],[-148,495],[-79,482],[-192,406],[-124,97],[-184,563],[-32,411],[12,350],[-159,655],[-130,231],[-150,122],[-92,339],[15,133],[-77,306],[-81,132],[-108,440],[-170,476],[-141,406],[-139,-3],[44,325],[12,206],[34,236]],[[36483,4468],[141,0],[414,127],[419,-127],[342,-255],[120,-359],[33,-254],[11,-301],[-430,-186],[-452,-150],[-522,-139],[-582,-116],[-658,35],[-365,197],[49,243],[593,162],[239,197],[174,254],[126,220],[168,209],[180,243]],[[31586,3163],[625,-23],[599,-58],[207,243],[147,208],[288,-243],[-82,-301],[-81,-266],[-582,81],[-621,-35],[-348,197],[0,23],[-152,174]],[[29468,8472],[190,70],[321,-23],[82,301],[16,219],[-6,475],[158,278],[256,93],[147,-220],[65,-220],[120,-267],[92,-254],[76,-267],[33,-266],[-49,-231],[-76,-220],[-326,-81],[-311,-116],[-364,11],[136,232],[-327,-81],[-310,-81],[-212,174],[-16,243],[305,231]],[[21575,8103],[174,104],[353,-81],[403,-46],[305,-81],[304,69],[163,-335],[-217,46],[-337,-23],[-343,23],[-376,-35],[-283,116],[-146,243]],[[15938,7061],[60,197],[332,-104],[359,-93],[332,104],[-158,-208],[-261,-151],[-386,47],[-278,208]],[[14643,7177],[202,127],[277,-139],[425,-231],[-164,23],[-359,58],[-381,162]],[[4524,4144],[169,220],[517,-93],[277,-185],[212,-209],[76,-266],[-533,-81],[-364,208],[-163,209],[-11,35],[-180,162]],[[0,529],[16,-5],[245,344],[501,-185],[32,21],[294,188],[38,-7],[32,-4],[402,-246],[352,246],[63,34],[816,104],[265,-138],[130,-71],[419,-196],[789,-151],[625,-185],[1072,-139],[800,162],[1181,-116],[669,-185],[734,174],[773,162],[60,278],[-1094,23],[-898,139],[-234,231],[-745,128],[49,266],[103,243],[104,220],[-55,243],[-462,162],[-212,209],[-430,185],[675,-35],[642,93],[402,-197],[495,173],[457,220],[223,197],[-98,243],[-359,162],[-408,174],[-571,35],[-500,81],[-539,58],[-180,220],[-359,185],[-217,208],[-87,672],[136,-58],[250,-185],[457,58],[441,81],[228,-255],[441,58],[370,127],[348,162],[315,197],[419,58],[-11,220],[-97,220],[81,208],[359,104],[163,-196],[425,115],[321,151],[397,12],[375,57],[376,139],[299,128],[337,127],[218,-35],[190,-46],[414,81],[370,-104],[381,11],[364,81],[375,-57],[414,-58],[386,23],[403,-12],[413,-11],[381,23],[283,174],[337,92],[349,-127],[331,104],[300,208],[179,-185],[98,-208],[180,-197],[288,174],[332,-220],[375,-70],[321,-162],[392,35],[354,104],[418,-23],[376,-81],[381,-104],[147,254],[-180,197],[-136,209],[-359,46],[-158,220],[-60,220],[-98,440],[213,-81],[364,-35],[359,35],[327,-93],[283,-174],[119,-208],[376,-35],[359,81],[381,116],[342,70],[283,-139],[370,46],[239,451],[224,-266],[321,-104],[348,58],[228,-232],[365,-23],[337,-69],[332,-128],[218,220],[108,209],[278,-232],[381,58],[283,-127],[190,-197],[370,58],[288,127],[283,151],[337,81],[392,69],[354,81],[272,127],[163,186],[65,254],[-32,244],[-87,231],[-98,232],[-87,231],[-71,209],[-16,231],[27,232],[130,220],[109,243],[44,231],[-55,255],[-32,232],[136,266],[152,173],[180,220],[190,186],[223,173],[109,255],[152,162],[174,151],[267,34],[174,186],[196,115],[228,70],[202,150],[157,186],[218,69],[163,-151],[-103,-196],[-283,-174],[-120,-127],[-206,92],[-229,-58],[-190,-139],[-202,-150],[-136,-174],[-38,-231],[17,-220],[130,-197],[-190,-139],[-261,-46],[-153,-197],[-163,-185],[-174,-255],[-44,-220],[98,-243],[147,-185],[229,-139],[212,-185],[114,-232],[60,-220],[82,-232],[130,-196],[82,-220],[38,-544],[81,-220],[22,-232],[87,-231],[-38,-313],[-152,-243],[-163,-197],[-370,-81],[-125,-208],[-169,-197],[-419,-220],[-370,-93],[-348,-127],[-376,-128],[-223,-243],[-446,-23],[-489,23],[-441,-46],[-468,0],[87,-232],[424,-104],[311,-162],[174,-208],[-310,-185],[-479,58],[-397,-151],[-17,-243],[-11,-232],[327,-196],[60,-220],[353,-220],[588,-93],[500,-162],[398,-185],[506,-186],[690,-92],[681,-162],[473,-174],[517,-197],[272,-278],[136,-220],[337,209],[457,173],[484,186],[577,150],[495,162],[691,12],[680,-81],[560,-139],[180,255],[386,173],[702,12],[550,127],[522,128],[577,81],[614,104],[430,150],[-196,209],[-119,208],[0,220],[-539,-23],[-571,-93],[-544,0],[-77,220],[39,440],[125,128],[397,138],[468,139],[337,174],[337,174],[251,231],[380,104],[376,81],[190,47],[430,23],[408,81],[343,116],[337,139],[305,139],[386,185],[245,197],[261,173],[82,232],[-294,139],[98,243],[185,185],[288,116],[305,139],[283,185],[217,232],[136,277],[202,163],[331,-35],[136,-197],[332,-23],[11,220],[142,231],[299,-58],[71,-220],[331,-34],[360,104],[348,69],[315,-34],[120,-243],[305,196],[283,105],[315,81],[310,81],[283,139],[310,92],[240,128],[168,208],[207,-151],[288,81],[202,-277],[157,-209],[316,116],[125,232],[283,162],[365,-35],[108,-220],[229,220],[299,69],[326,23],[294,-11],[310,-70],[300,-34],[130,-197],[180,-174],[304,104],[327,24],[315,0],[310,11],[278,81],[294,70],[245,162],[261,104],[283,58],[212,162],[152,324],[158,197],[288,-93],[109,-208],[239,-139],[289,46],[196,-208],[206,-151],[283,139],[98,255],[250,104],[289,197],[272,81],[326,116],[218,127],[228,139],[218,127],[261,-69],[250,208],[180,162],[261,-11],[229,139],[54,208],[234,162],[228,116],[278,93],[256,46],[244,-35],[262,-58],[223,-162],[27,-254],[245,-197],[168,-162],[332,-70],[185,-162],[229,-162],[266,-35],[223,116],[240,243],[261,-127],[272,-70],[261,-69],[272,-46],[277,0],[229,-614],[-11,-150],[-33,-267],[-266,-150],[-218,-220],[38,-232],[310,12],[-38,-232],[-141,-220],[-131,-243],[212,-185],[321,-58],[321,104],[153,232],[92,220],[153,185],[174,174],[70,208],[147,289],[174,58],[316,24],[277,69],[283,93],[136,231],[82,220],[190,220],[272,151],[234,115],[153,197],[157,104],[202,93],[277,-58],[250,58],[272,69],[305,-34],[201,162],[142,393],[103,-162],[131,-278],[234,-115],[266,-47],[267,70],[283,-46],[261,-12],[174,58],[234,-35],[212,-127],[250,81],[300,0],[255,81],[289,-81],[185,197],[141,196],[191,163],[348,439],[179,-81],[212,-162],[185,-208],[354,-359],[272,-12],[256,0],[299,70],[299,81],[229,162],[190,174],[310,23],[207,127],[218,-116],[141,-185],[196,-185],[305,23],[190,-150],[332,-151],[348,-58],[288,47],[218,185],[185,185],[250,46],[251,-81],[288,-58],[261,93],[250,0],[245,-58],[256,-58],[250,104],[299,93],[283,23],[316,0],[255,58],[251,46],[76,290],[11,243],[174,-162],[49,-266],[92,-244],[115,-196],[234,-105],[315,35],[365,12],[250,35],[364,0],[262,11],[364,-23],[310,-46],[196,-186],[-54,-220],[179,-173],[299,-139],[310,-151],[360,-104],[375,-92],[283,-93],[315,-12],[180,197],[245,-162],[212,-185],[245,-139],[337,-58],[321,-69],[136,-232],[316,-139],[212,-208],[310,-93],[321,12],[299,-35],[332,12],[332,-47],[310,-81],[288,-139],[289,-116],[195,-173],[-32,-232],[-147,-208],[-125,-266],[-98,-209],[-131,-243],[-364,-93],[-163,-208],[-360,-127],[-125,-232],[-190,-220],[-201,-185],[-115,-243],[-70,-220],[-28,-266],[6,-220],[158,-232],[60,-220],[130,-208],[517,-81],[109,-255],[-501,-93],[-424,-127],[-528,-23],[-234,-336],[-49,-278],[-119,-220],[-147,-220],[370,-196],[141,-244],[239,-219],[338,-197],[386,-186],[419,-185],[636,-185],[142,-289],[800,-128],[53,-45],[208,-175],[767,151],[636,-186],[479,-142],[-99999,0]],[[59092,71341],[19,3],[40,143],[200,-8],[253,176],[-188,-251],[21,-111]],[[59437,71293],[-30,21],[-53,-45],[-42,12],[-14,-22],[-5,59],[-20,37],[-54,6],[-75,-51],[-52,31]],[[59437,71293],[8,-48],[-285,-240],[-136,77],[-64,237],[132,22]],[[45272,63236],[13,274],[106,161],[91,308],[-18,200],[96,417],[155,376],[93,95],[74,344],[6,315],[100,365],[185,216],[177,603],[5,8],[139,227],[259,65],[218,404],[140,158],[232,493],[-70,735],[106,508],[37,312],[179,399],[278,270],[206,244],[186,612],[87,362],[205,-2],[167,-251],[264,41],[288,-131],[121,-6]],[[56944,63578],[0,2175],[0,2101],[-83,476],[71,365],[-43,253],[101,283]],[[56990,69231],[369,10],[268,-156],[275,-175],[129,-92],[214,188],[114,169],[245,49],[198,-75],[75,-293],[65,193],[222,-140],[217,-33],[137,149]],[[59700,68010],[-78,-238],[-60,-446],[-75,-308],[-65,-103],[-93,191],[-125,263],[-198,847],[-29,-53],[115,-624],[171,-594],[210,-920],[102,-321],[90,-334],[249,-654],[-55,-103],[9,-384],[323,-530],[49,-121]],[[53191,70158],[326,-204],[117,51],[232,-98],[368,-264],[130,-526],[250,-114],[391,-248],[296,-293],[136,153],[133,272],[-65,452],[87,288],[200,277],[192,80],[375,-121],[95,-264],[104,-2],[88,-101],[276,-70],[68,-195]],[[59804,53833],[-164,643],[-127,137],[-48,236],[-141,288],[-171,42],[95,337],[147,14],[42,181]],[[61764,57990],[-98,-261],[-94,-277],[22,-163],[4,-180],[155,-10],[67,42],[62,-106]],[[61882,57035],[-61,-209],[103,-325],[102,-285],[106,-210],[909,-702],[233,4]],[[61966,58083],[66,-183],[-9,-245],[-158,-142],[119,-161]],[[61984,57352],[-102,-317]],[[61984,57352],[91,-109],[54,-245],[125,-247],[138,-2],[262,151],[302,70],[245,184],[138,39],[99,108],[158,20]],[[58449,49909],[-166,-182],[-67,60]],[[58564,52653],[115,161],[176,-132],[224,138],[195,-1],[171,272]],[[55279,77084],[100,2],[-69,-260],[134,-227],[-41,-278],[-65,-27]],[[55338,76294],[-52,-53],[-90,-138],[-41,-325]],[[55719,75309],[35,-5],[13,121],[164,91],[62,23]],[[55993,75539],[95,35],[128,9]],[[55993,75539],[-9,44],[33,71],[31,144],[-39,-4],[-54,110],[-46,28],[-36,94],[-52,36],[-40,84],[-50,-33],[-38,-196],[-66,-43]],[[55627,75874],[22,51],[-106,123],[-91,63],[-40,82],[-74,101]],[[55380,75322],[-58,46],[-78,192],[-120,118]],[[55627,75874],[-52,-132]],[[32866,56937],[160,77],[58,-21],[-11,-440],[-232,-65],[-50,53],[81,163],[-6,233]]],"bbox":[-180,-85.60903777459771,180,83.64513000000001],"transform":{"scale":[0.0036000360003600037,0.0016925586033320105],"translate":[-180,-85.60903777459771]}} diff --git a/frontend/public/countries-110m.json.LICENSE b/frontend/public/countries-110m.json.LICENSE new file mode 100644 index 00000000..a24a8ec0 --- /dev/null +++ b/frontend/public/countries-110m.json.LICENSE @@ -0,0 +1,13 @@ +Copyright 2013-2019 Michael Bostock + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/frontend/src/components/dashboard/AttackOriginMap.jsx b/frontend/src/components/dashboard/AttackOriginMap.jsx new file mode 100644 index 00000000..574f7394 --- /dev/null +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -0,0 +1,267 @@ +import React from "react"; +import { + ComposableMap, + Geographies, + Geography, + ZoomableGroup, +} from "react-simple-maps"; +import axios from "axios"; +import { useTimePickerStore } from "@certego/certego-ui"; +import { IOC_ATTACKER_COUNTRIES_URI } from "../../constants/api"; +const WORLD_ATLAS_GEO_URL = `${import.meta.env.BASE_URL}countries-110m.json`; + +// Normalise country names from T-Pot geoip to match Natural Earth names used by world-atlas@2. (https://github.com/topojson/world-atlas) +const 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", +}; + +function normalise(name) { + return NAME_FIXES[name] ?? name; +} + +// Interpolate between two hex colours by t ∈ [0, 1] +function lerpColor(a, b, t) { + const ah = parseInt(a.replace("#", ""), 16); + const bh = parseInt(b.replace("#", ""), 16); + const ar = (ah >> 16) & 0xff; + const ag = (ah >> 8) & 0xff; + const ab = ah & 0xff; + const br = (bh >> 16) & 0xff; + const bg = (bh >> 8) & 0xff; + const bb = bh & 0xff; + const rr = Math.round(ar + (br - ar) * t); + const rg = Math.round(ag + (bg - ag) * t); + const rb = Math.round(ab + (bb - ab) * t); + return `rgb(${rr},${rg},${rb})`; +} + +const COLOR_EMPTY = "#2a2a3a"; +const COLOR_LOW = "#ffffb2"; +const COLOR_MID = "#fd8d3c"; +const COLOR_HIGH = "#bd0026"; + +export default function AttackOriginMap() { + const { range } = useTimePickerStore(); + const [countryData, setCountryData] = React.useState({}); + const [maxCount, setMaxCount] = React.useState(1); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + // tooltip state + const [tooltip, setTooltip] = React.useState({ + visible: false, + x: 0, + y: 0, + name: "", + count: 0, + }); + + React.useEffect(() => { + setLoading(true); + setError(null); + axios + .get(IOC_ATTACKER_COUNTRIES_URI, { params: { range } }) + .then((resp) => { + const map = {}; + let max = 0; + resp.data.forEach(({ country, count }) => { + const key = normalise(country); + map[key] = count; + if (count > max) max = count; + }); + setCountryData(map); + setMaxCount(max); + }) + .catch((err) => { + console.error("AttackOriginMap error:", err); + setError("Failed to load map data."); + }) + .finally(() => setLoading(false)); + }, [range]); + + const getColor = React.useCallback( + (geoName) => { + const count = countryData[geoName]; + if (!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) + if (t < 0.5) return lerpColor(COLOR_LOW, COLOR_MID, t * 2); + return lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); + }, + [countryData, maxCount], + ); + + const handleMouseEnter = React.useCallback( + (geo, evt) => { + const name = geo.properties.name; + const count = countryData[name] ?? 0; + setTooltip({ + visible: true, + x: evt.clientX, + y: evt.clientY, + name, + count, + }); + }, + [countryData], + ); + + const handleMouseMove = React.useCallback((evt) => { + setTooltip((prev) => + prev.visible ? { ...prev, x: evt.clientX, y: evt.clientY } : prev, + ); + }, []); + + const handleMouseLeave = React.useCallback(() => { + setTooltip((prev) => ({ ...prev, visible: false })); + }, []); + + if (loading) { + return ( +
+ Loading map… +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {/* Tooltip */} + {tooltip.visible && ( +
+ {tooltip.name} + {tooltip.count > 0 ? ( + <> +
+ + {tooltip.count.toLocaleString()} IOC + {tooltip.count !== 1 ? "s" : ""} + + + ) : ( + <> +
+ No data + + )} +
+ )} + + {/* Map */} +
+ + + + {({ geographies }) => + geographies.map((geo) => ( + handleMouseEnter(geo, evt)} + onMouseLeave={handleMouseLeave} + style={{ + default: { outline: "none" }, + hover: { + outline: "none", + fill: "#facc15", + transition: "fill 80ms", + }, + pressed: { outline: "none" }, + }} + /> + )) + } + + + +
+ + {/* Colour-scale legend*/} + {maxCount > 0 && ( +
+ 0 +
+ {maxCount.toLocaleString()} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/Dashboard.jsx b/frontend/src/components/dashboard/Dashboard.jsx index fb4f8fbc..3af40edc 100644 --- a/frontend/src/components/dashboard/Dashboard.jsx +++ b/frontend/src/components/dashboard/Dashboard.jsx @@ -13,9 +13,11 @@ import { EnrichmentSourcesChart, EnrichmentRequestsChart, FeedsTypesChart, + AttackOriginCountriesChart, } from "./utils/charts"; import EnrichmentLookup from "./EnrichmentLookup"; +import AttackOriginMap from "./AttackOriginMap"; const feedsChartList = [ ["FeedsSourcesChart", "Feeds: Sources", FeedsSourcesChart], @@ -118,6 +120,32 @@ function Dashboard() { ))} + + + + +
+ } + style={{ height: "100%" }} + /> + + + + + + } + style={{ height: "100%" }} + /> + + ); } diff --git a/frontend/src/components/dashboard/utils/charts.jsx b/frontend/src/components/dashboard/utils/charts.jsx index f1304261..992b43a5 100644 --- a/frontend/src/components/dashboard/utils/charts.jsx +++ b/frontend/src/components/dashboard/utils/charts.jsx @@ -1,17 +1,34 @@ import React from "react"; -import { Bar, Area } from "recharts"; +import { + Bar, + Area, + BarChart, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import axios from "axios"; -import { AnyChartWidget, getRandomColorsArray } from "@certego/certego-ui"; +import { + AnyChartWidget, + getRandomColorsArray, + useTimePickerStore, +} from "@certego/certego-ui"; import { FEEDS_STATISTICS_SOURCES_URI, FEEDS_STATISTICS_DOWNLOADS_URI, FEEDS_STATISTICS_TYPES_URI, ENRICHMENT_STATISTICS_SOURCES_URI, ENRICHMENT_STATISTICS_REQUESTS_URI, + IOC_ATTACKER_COUNTRIES_URI, } from "../../../constants/api"; import { FEED_COLOR_MAP, ENRICHMENT_COLOR_MAP } from "../../../constants"; +const COUNTRY_BAR_COLOR = "#e05252"; + // constants const colors = getRandomColorsArray(30, true); @@ -149,3 +166,93 @@ export const FeedsTypesChart = React.memo(() => { return ; }); + +export const AttackOriginCountriesChart = React.memo(() => { + console.debug("AttackOriginCountriesChart rendered!"); + + const { range } = useTimePickerStore(); + const [data, setData] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + setLoading(true); + setError(null); + axios + .get(IOC_ATTACKER_COUNTRIES_URI, { params: { range } }) + .then((resp) => setData(resp.data)) + .catch((err) => { + console.error("AttackOriginCountriesChart error:", err); + setError("Failed to load country data."); + }) + .finally(() => setLoading(false)); + }, [range]); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data || data.length === 0) { + return ( +
+ No country data available for the selected time range. +
+ ); + } + + const chartData = data.slice(0, 15); + + return ( + + + + + [value.toLocaleString(), "IOCs"]} + /> + + {chartData.map((entry, index) => ( + + ))} + + + + ); +}); diff --git a/frontend/src/constants/api.js b/frontend/src/constants/api.js index 256161b1..710fd61c 100644 --- a/frontend/src/constants/api.js +++ b/frontend/src/constants/api.js @@ -7,6 +7,7 @@ export const FEEDS_STATISTICS_DOWNLOADS_URI = `${API_BASE_URI}/statistics/downlo export const FEEDS_STATISTICS_TYPES_URI = `${API_BASE_URI}/statistics/feeds_types`; export const ENRICHMENT_STATISTICS_SOURCES_URI = `${API_BASE_URI}/statistics/sources/enrichment`; export const ENRICHMENT_STATISTICS_REQUESTS_URI = `${API_BASE_URI}/statistics/requests/enrichment`; +export const IOC_ATTACKER_COUNTRIES_URI = `${API_BASE_URI}/statistics/countries`; // user export const USERACCESS_URI = `${API_BASE_URI}/me/access`; diff --git a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx new file mode 100644 index 00000000..421cdb56 --- /dev/null +++ b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx @@ -0,0 +1,115 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import axios from "axios"; +import { AttackOriginCountriesChart } from "../../../src/components/dashboard/utils/charts"; +import { IOC_ATTACKER_COUNTRIES_URI } from "../../../src/constants/api"; + +vi.mock("axios"); + +vi.mock("@certego/certego-ui", () => ({ + useTimePickerStore: () => ({ range: "7d" }), + getRandomColorsArray: (n) => Array(n).fill("#aabbcc"), + AnyChartWidget: () =>
, +})); + +// ResponsiveContainer requires a DOM-measured width; give it fixed dimensions in jsdom +vi.mock("recharts", async (importOriginal) => { + const original = await importOriginal(); + const ResponsiveContainer = ({ children, height }) => ( +
+ {React.cloneElement(React.Children.only(children), { + width: 800, + height, + })} +
+ ); + return { ...original, ResponsiveContainer }; +}); + +const COUNTRIES_DATA = [ + { country: "China", count: 120 }, + { country: "United States", count: 80 }, + { country: "Russia", count: 60 }, + { country: "Germany", count: 40 }, + { country: "India", count: 30 }, +]; + +// 16 entries (one more than the 15-entry limit) +const SIXTEEN_COUNTRIES = Array.from({ length: 16 }, (_, i) => ({ + country: `Country${i + 1}`, + count: 100 - i, +})); + +describe("AttackOriginCountriesChart", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("shows loading state while request is in flight", () => { + axios.get.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + test("shows error message when request fails", async () => { + axios.get.mockRejectedValue(new Error("Network error")); + render(); + await waitFor(() => + expect( + screen.getByText("Failed to load country data."), + ).toBeInTheDocument(), + ); + }); + + test("shows empty-state message when response is an empty array", async () => { + axios.get.mockResolvedValue({ data: [] }); + render(); + await waitFor(() => + expect( + screen.getByText( + "No country data available for the selected time range.", + ), + ).toBeInTheDocument(), + ); + }); + + test("calls the countries endpoint with the range param", async () => { + axios.get.mockResolvedValue({ data: [] }); + render(); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith(IOC_ATTACKER_COUNTRIES_URI, { + params: { range: "7d" }, + }), + ); + }); + + test("renders the chart container when data is present", async () => { + axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); + render(); + await waitFor(() => + expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), + ); + }); + + test("caps rendered data at 15 entries", async () => { + axios.get.mockResolvedValue({ data: SIXTEEN_COUNTRIES }); + render(); + await waitFor(() => + expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), + ); + // The 16th country name must not appear in the rendered output + expect(screen.queryByText("Country16")).not.toBeInTheDocument(); + }); + + test("chart height scales with number of entries", async () => { + axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); + const { container } = render(); + await waitFor(() => + expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), + ); + const rc = screen.getByTestId("responsive-container"); + // 5 entries × 28px = 140, but minimum is 180 + expect(rc.style.height).toBe("180px"); + }); +}); diff --git a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx new file mode 100644 index 00000000..0f3d553e --- /dev/null +++ b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx @@ -0,0 +1,133 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import axios from "axios"; +import AttackOriginMap from "../../../src/components/dashboard/AttackOriginMap"; +import { IOC_ATTACKER_COUNTRIES_URI } from "../../../src/constants/api"; + +vi.mock("axios"); + +vi.mock("@certego/certego-ui", () => ({ + useTimePickerStore: () => ({ range: "7d" }), +})); + +// Mock react-simple-maps components +vi.mock("react-simple-maps", () => ({ + ComposableMap: ({ children, onMouseMove, onMouseLeave }) => ( +
+ {children} +
+ ), + 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" } }, + ], + }), + Geography: ({ fill, onMouseEnter, onMouseLeave, geography }) => ( +
+ ), + ZoomableGroup: ({ children }) =>
{children}
, +})); + +const COUNTRIES_DATA = [ + { country: "China", count: 120 }, + { country: "United States", count: 80 }, + { country: "Germany", count: 40 }, +]; + +describe("AttackOriginMap", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("shows loading state while request is in flight", () => { + axios.get.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText("Loading map…")).toBeInTheDocument(); + }); + + test("shows error state when request fails", async () => { + axios.get.mockRejectedValue(new Error("Network error")); + render(); + await waitFor(() => + expect(screen.getByText("Failed to load map data.")).toBeInTheDocument(), + ); + }); + + test("calls the countries endpoint with the range param", async () => { + axios.get.mockResolvedValue({ data: [] }); + render(); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith(IOC_ATTACKER_COUNTRIES_URI, { + params: { range: "7d" }, + }), + ); + }); + + test("renders map and hides legend when response is empty", async () => { + axios.get.mockResolvedValue({ data: [] }); + render(); + await waitFor(() => + expect(screen.getByTestId("composable-map")).toBeInTheDocument(), + ); + // maxCount stays 0 (legend must not render) + expect(screen.queryByText("0")).not.toBeInTheDocument(); + }); + + test("renders map and shows legend with correct maxCount when data is present", async () => { + axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); + render(); + await waitFor(() => + expect(screen.getByTestId("composable-map")).toBeInTheDocument(), + ); + // Legend: left label "0" and right label matching the max value + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.getByText("120")).toBeInTheDocument(); + }); + + test("geographies are rendered for each geo in the response", async () => { + axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); + render(); + await waitFor(() => + expect(screen.getByTestId("geography-geo-cn")).toBeInTheDocument(), + ); + expect(screen.getByTestId("geography-geo-usa")).toBeInTheDocument(); + }); + + test("empty country for a geo gets the empty fill colour", async () => { + axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); + render(); + await waitFor(() => + expect(screen.getByTestId("geography-geo-fr")).toBeInTheDocument(), + ); + // France 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 + 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 () => { + axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); + render(); + await waitFor(() => + expect(screen.getByTestId("geography-geo-usa")).toBeInTheDocument(), + ); + + const usaEl = screen.getByTestId("geography-geo-usa"); + expect(usaEl.dataset.fill).not.toBe("#2a2a3a"); + }); +}); diff --git a/frontend/tests/components/dashboard/Dashboard.test.jsx b/frontend/tests/components/dashboard/Dashboard.test.jsx index 8cb37674..a1590ba0 100644 --- a/frontend/tests/components/dashboard/Dashboard.test.jsx +++ b/frontend/tests/components/dashboard/Dashboard.test.jsx @@ -15,6 +15,7 @@ vi.mock( const EnrichmentSourcesChart = () =>
; const EnrichmentRequestsChart = () =>
; const FeedsTypesChart = () =>
; + const AttackOriginCountriesChart = () =>
; return { ...originalChartModule, @@ -23,10 +24,15 @@ vi.mock( EnrichmentSourcesChart, EnrichmentRequestsChart, FeedsTypesChart, + AttackOriginCountriesChart, }; }, ); +vi.mock("../../../src/components/dashboard/AttackOriginMap", () => ({ + default: () =>
, +})); + describe("Dashboard component", () => { test("Dashboard", () => { render( diff --git a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx index ad0f7580..1e060958 100644 --- a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx +++ b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx @@ -20,6 +20,7 @@ vi.mock( const EnrichmentSourcesChart = () =>
; const EnrichmentRequestsChart = () =>
; const FeedsTypesChart = () =>
; + const AttackOriginCountriesChart = () =>
; return { ...originalChartModule, @@ -28,10 +29,15 @@ vi.mock( EnrichmentSourcesChart, EnrichmentRequestsChart, FeedsTypesChart, + AttackOriginCountriesChart, }; }, ); +vi.mock("../../../src/components/dashboard/AttackOriginMap", () => ({ + default: () =>
, +})); + // Mock useAuthStore const mockUseAuthStore = vi.fn(); vi.mock("../../../src/stores", () => ({ diff --git a/tests/__init__.py b/tests/__init__.py index 0555535e..798facc1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -49,6 +49,7 @@ def setUpTestData(cls): login_attempts=1, recurrence_probability=0.1, expected_interactions=11.1, + attacker_country="China", ) cls.ioc_2 = IOC.objects.create( @@ -69,6 +70,7 @@ def setUpTestData(cls): login_attempts=1, recurrence_probability=0.1, expected_interactions=11.1, + attacker_country="China", ) cls.ioc_3 = IOC.objects.create( @@ -89,6 +91,7 @@ def setUpTestData(cls): login_attempts=1, recurrence_probability=0.1, expected_interactions=11.1, + attacker_country="United States", ) cls.ioc_domain = IOC.objects.create( @@ -127,6 +130,21 @@ def setUpTestData(cls): cls.ioc_domain.general_honeypot.add(cls.log4pot_hp) # Log4pot honeypot cls.ioc_domain.save() + # IOC with an inactive-only honeypot + cls.ioc_inactive_country = IOC.objects.create( + name="1.2.3.7", + type=IocType.IP.value, + first_seen=cls.current_time, + last_seen=cls.current_time, + days_seen=[cls.current_time], + number_of_days_seen=1, + attack_count=1, + interaction_count=1, + attacker_country="Russia", + ) + cls.ioc_inactive_country.general_honeypot.add(cls.ddospot) + cls.ioc_inactive_country.save() + cls.cmd_seq = ["cd foo", "ls -la"] cls.hash = sha256("\n".join(cls.cmd_seq).encode()).hexdigest() cls.command_sequence = CommandSequence.objects.create( diff --git a/tests/api/views/test_statistics_view.py b/tests/api/views/test_statistics_view.py index f45f2bb9..c7171039 100644 --- a/tests/api/views/test_statistics_view.py +++ b/tests/api/views/test_statistics_view.py @@ -50,3 +50,21 @@ def test_200_feed_types(self): self.assertEqual(response.json()[0]["Log4pot"], 3) self.assertEqual(response.json()[0]["Cowrie"], 3) self.assertEqual(response.json()[0]["Tanner"], 0) + + def test_200_countries(self): + response = self.client.get("/api/statistics/countries") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsInstance(data, list) + countries = [item["country"] for item in data] + counts = {item["country"]: item["count"] for item in data} + # China appears on ioc + ioc_2 (both active), United States on ioc_3 (active) + # Russia is only on ioc_inactive_country (ddospot, inactive); must be excluded + self.assertIn("China", countries) + self.assertIn("United States", countries) + self.assertNotIn("Russia", countries) + self.assertEqual(counts["China"], 2) + self.assertEqual(counts["United States"], 1) + # 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 9ca27474e95ab47711946b2f30d80e4348e6e6b8 Mon Sep 17 00:00:00 2001 From: Tanmay Joddar <152395649+tanmayjoddar@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:54:36 +0530 Subject: [PATCH 051/109] fix(extraction): add sort guard to _update_days_seen and tests. Closes #1007 (#1010) --- .../cronjobs/extraction/ioc_processor.py | 8 ++-- tests/test_ioc_processor.py | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/greedybear/cronjobs/extraction/ioc_processor.py b/greedybear/cronjobs/extraction/ioc_processor.py index a3a9bf3b..f909b306 100644 --- a/greedybear/cronjobs/extraction/ioc_processor.py +++ b/greedybear/cronjobs/extraction/ioc_processor.py @@ -128,7 +128,9 @@ def _update_days_seen(self, ioc: IOC) -> IOC: Returns: The updated IOC record. """ - if len(ioc.days_seen) == 0 or ioc.days_seen[-1] != ioc.last_seen.date(): - ioc.days_seen.append(ioc.last_seen.date()) - ioc.number_of_days_seen = len(ioc.days_seen) + new_date = ioc.last_seen.date() + if new_date not in ioc.days_seen: + ioc.days_seen.append(new_date) + ioc.days_seen.sort() + ioc.number_of_days_seen = len(ioc.days_seen) return ioc diff --git a/tests/test_ioc_processor.py b/tests/test_ioc_processor.py index d23a4f4d..9d0d3667 100644 --- a/tests/test_ioc_processor.py +++ b/tests/test_ioc_processor.py @@ -397,3 +397,48 @@ def test_handles_date_boundaries(self): result.last_seen = datetime(2025, 1, 2, 0, 0, 0) result = self.processor._update_days_seen(result) self.assertEqual(len(result.days_seen), 2) + + def test_sort_guard_non_chronological(self): + """Sort guard heals non-chronological days_seen.""" + ioc = self._create_mock_ioc( + last_seen=datetime(2025, 1, 4, 12, 0, 0), + ) + ioc.days_seen = [date(2025, 1, 5)] + ioc.number_of_days_seen = 1 + + result = self.processor._update_days_seen(ioc) + + self.assertEqual( + result.days_seen, + [date(2025, 1, 4), date(2025, 1, 5)], + ) + self.assertEqual(result.number_of_days_seen, 2) + + def test_sort_guard_adjacent_day_reversal(self): + """Adjacent-day reversal is sorted — no ZeroDivisionError path.""" + ioc = self._create_mock_ioc( + last_seen=datetime(2025, 1, 4, 23, 58, 0), + ) + ioc.days_seen = [date(2025, 1, 5)] + ioc.number_of_days_seen = 1 + + result = self.processor._update_days_seen(ioc) + + self.assertEqual( + result.days_seen, + [date(2025, 1, 4), date(2025, 1, 5)], + ) + self.assertEqual(result.number_of_days_seen, 2) + + def test_sort_guard_no_duplicate(self): + """Duplicate date is not appended.""" + ioc = self._create_mock_ioc( + last_seen=datetime(2025, 1, 5, 10, 0, 0), + ) + ioc.days_seen = [date(2025, 1, 5)] + ioc.number_of_days_seen = 1 + + result = self.processor._update_days_seen(ioc) + + self.assertEqual(len(result.days_seen), 1) + self.assertEqual(result.number_of_days_seen, 1) From 59c5ba491837e0e91e91fc9f264cef003cd30da9 Mon Sep 17 00:00:00 2001 From: R1sh0bh-1 Date: Wed, 11 Mar 2026 16:27:06 +0530 Subject: [PATCH 052/109] Fix unauthenticated feeds memory exhaustion DoS. Closes #844 (#993) * fix: filter unauthenticated feeds query params to prevent Memory Exhaustion DoS (#844) * refactor: consolidate query param allowlist and strengthen filter tests --- api/views/feeds.py | 23 ++++++++++++++++++----- tests/api/views/test_feeds_view.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/api/views/feeds.py b/api/views/feeds.py index 521876bb..5cc29be1 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -31,6 +31,15 @@ logger = logging.getLogger(__name__) +ALLOWED_UNAUTHENTICATED_QUERY_PARAMS = [ + "feed_type", + "attack_type", + "ioc_type", + "include_mass_scanners", + "include_tor_exit_nodes", + "prioritize", +] + @api_view([GET]) @throttle_classes([FeedsThrottle]) @@ -52,10 +61,12 @@ def feeds(request, feed_type, attack_type, prioritize, format_): """ logger.info(f"request /api/feeds with params: feed type: {feed_type}, attack_type: {attack_type}, prioritization: {prioritize}, format: {format_}") - feed_params_data = request.query_params.dict() + filtered_query_params = {key: request.query_params.get(key) for key in ALLOWED_UNAUTHENTICATED_QUERY_PARAMS if key in request.query_params} + + feed_params_data = filtered_query_params.copy() feed_params_data.update({"feed_type": feed_type, "attack_type": attack_type, "format": format_}) feed_params = FeedRequestParams(feed_params_data) - feed_params.apply_default_filters(request.query_params) + feed_params.apply_default_filters(filtered_query_params) feed_params.set_prioritization(prioritize) valid_feed_types = get_valid_feed_types() @@ -78,10 +89,12 @@ def feeds_pagination(request): logger.info(f"request /api/feeds with params: {request.query_params}") - feed_params = FeedRequestParams(request.query_params) + filtered_query_params = {key: request.query_params.get(key) for key in ALLOWED_UNAUTHENTICATED_QUERY_PARAMS if key in request.query_params} + + feed_params = FeedRequestParams(filtered_query_params) feed_params.format = "json" - feed_params.apply_default_filters(request.query_params) - feed_params.set_prioritization(request.query_params.get("prioritize")) + feed_params.apply_default_filters(filtered_query_params) + feed_params.set_prioritization(filtered_query_params.get("prioritize")) valid_feed_types = get_valid_feed_types() iocs_queryset = get_queryset(request, feed_params, valid_feed_types) diff --git a/tests/api/views/test_feeds_view.py b/tests/api/views/test_feeds_view.py index 1ed9362d..8918c152 100644 --- a/tests/api/views/test_feeds_view.py +++ b/tests/api/views/test_feeds_view.py @@ -72,6 +72,14 @@ def test_200_feeds_scanner_inclusion(self): # Expecting 3 because setupTestData creates 3 IOCs (ioc, ioc_2, ioc_domain) associated with Heralding self.assertEqual(len(response.json()["iocs"]), 3) + def test_200_feeds_ignores_undocumented_params(self): + # Setup data has multiple IOCs. We pass feed_size=1. + # If the undocumented param is correctly ignored/filtered, + # the response should fallback to the default feed_size and return >1 items. + response = self.client.get("/api/feeds/all/all/recent.json?feed_size=1") + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.json()["iocs"]), 1) + def test_400_feeds(self): response = self.client.get("/api/feeds/test/all/recent.json") self.assertEqual(response.status_code, 400) @@ -136,6 +144,14 @@ def test_200_feeds_multi_type_union(self): self.assertIn(self.ioc.name, returned_values) self.assertIn(self.ioc_domain.name, returned_values) + def test_200_feeds_pagination_ignores_undocumented_params(self): + # Setup data has multiple IOCs. We pass feed_size=1. + # If the undocumented param is correctly ignored/filtered, + # the pagination response should fallback to the default feed_size and return count > 1. + response = self.client.get("/api/feeds/?page_size=10&page=1&feed_type=all&attack_type=all&age=recent&feed_size=1") + self.assertEqual(response.status_code, 200) + self.assertGreater(response.json()["count"], 1) + def test_400_feeds_multi_type_with_invalid(self): response = self.client.get("/api/feeds/?page_size=10&page=1&feed_type=cowrie,invalid_type&attack_type=all&age=recent") self.assertEqual(response.status_code, 400) From 22cea3835300eadd0a7e8936cbf888e5863dee51 Mon Sep 17 00:00:00 2001 From: Tanmay Joddar <152395649+tanmayjoddar@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:11:49 +0530 Subject: [PATCH 053/109] test: add coverage for ClusterCommandSequences.run(). Closes #976 (#1005) * test: add coverage for ClusterCommandSequences.run(). Closes #976 * style: apply ruff formatting * fix: add strict= to zip() for ruff B905 --- tests/test_clustering.py | 70 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/tests/test_clustering.py b/tests/test_clustering.py index 2585086a..cd4900d7 100644 --- a/tests/test_clustering.py +++ b/tests/test_clustering.py @@ -1,4 +1,7 @@ -from greedybear.cronjobs.commands.cluster import tokenize +from unittest.mock import patch + +from greedybear.cronjobs.commands.cluster import ClusterCommandSequences, tokenize +from greedybear.models import CommandSequence from . import CustomTestCase @@ -81,3 +84,68 @@ def test_tokenize_mixed_content(self): "'update'", ] self.assertEqual(tokenize(input_seq), expected) + + +class ClusterCommandSequencesTestCase(CustomTestCase): + """Tests for ClusterCommandSequences.run() — covers lines 46–60.""" + + def _run_job(self, labels): + """Helper: run ClusterCommandSequences with mocked get_components().""" + with patch("greedybear.cronjobs.commands.cluster.LSHConnectedComponents") as mock_lsh_cls: + mock_lsh_cls.return_value.get_components.return_value = labels + ClusterCommandSequences().run() + + def test_run_empty_db(self): + """Early return when no CommandSequence objects exist (lines 47–49).""" + CommandSequence.objects.all().delete() + with patch("greedybear.cronjobs.commands.cluster.LSHConnectedComponents") as mock_lsh: + ClusterCommandSequences().run() + # get_components must NOT be called — we returned before reaching it + mock_lsh.return_value.get_components.assert_not_called() + + def test_run_no_label_changes(self): + """seqs_to_update stays empty → bulk_update skipped (line 59 else branch).""" + seqs = list(CommandSequence.objects.all()) + current_labels = [s.cluster for s in seqs] + self._run_job(current_labels) + for seq, original_label in zip(seqs, current_labels, strict=False): + seq.refresh_from_db() + self.assertEqual(seq.cluster, original_label) + + def test_run_partial_label_changes(self): + """Only the sequence whose label changed is written to DB (lines 53–59).""" + # Delete inherited fixtures and work with a single, known sequence so + # there is no ordering ambiguity between this call and run()'s queryset. + CommandSequence.objects.all().delete() + seq = CommandSequence.objects.create( + first_seen=self.current_time, + last_seen=self.current_time, + commands=["ls -la"], + commands_hash="testhash_partial", + cluster=11, + ) + self._run_job([999]) # single sequence, label changes 11 → 999 + seq.refresh_from_db() + self.assertEqual(seq.cluster, 999) + + def test_run_all_labels_changed(self): + """All sequences are updated when every label differs (lines 53–59).""" + seqs = list(CommandSequence.objects.all()) + new_labels = [777] * len(seqs) + self._run_job(new_labels) + for seq in seqs: + seq.refresh_from_db() + self.assertEqual(seq.cluster, 777) + + def test_run_bulk_update_called_with_correct_args(self): + """bulk_update is called with field='cluster' and batch_size=1000 (line 59).""" + seqs = list(CommandSequence.objects.all()) + new_labels = [888] * len(seqs) + with patch("greedybear.cronjobs.commands.cluster.LSHConnectedComponents") as mock_lsh_cls: + mock_lsh_cls.return_value.get_components.return_value = new_labels + with patch("greedybear.models.CommandSequence.objects.bulk_update") as mock_bulk: + ClusterCommandSequences().run() + mock_bulk.assert_called_once() + call_args = mock_bulk.call_args + self.assertIn("cluster", call_args[0][1]) + self.assertEqual(call_args[1].get("batch_size"), 1000) From 9dd54644a8cc51407e23ebc2f9fb3855e394fca9 Mon Sep 17 00:00:00 2001 From: SANCHIT KUMAR Date: Wed, 11 Mar 2026 18:05:53 +0530 Subject: [PATCH 054/109] fix: record statistics only after input validation in cowrie_session_view. Closes #1014 (#1021) Signed-off-by: Sanchit2662 --- api/views/cowrie_session.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index b2d12da6..6c1641bb 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -71,9 +71,6 @@ def cowrie_session_view(request): include_session_data = request.query_params.get("include_session_data", "false").lower() == "true" logger.info(f"Cowrie view requested by {request.user} for {observable}") - source_ip = str(request.META["REMOTE_ADDR"]) - request_source = Statistics(source=source_ip, view=ViewType.COWRIE_SESSION_VIEW.value) - request_source.save() if not observable: return HttpResponseBadRequest("Missing required 'query' parameter") @@ -92,6 +89,9 @@ def cowrie_session_view(request): else: return HttpResponseBadRequest("Query must be a valid IP address or SHA-256 hash") + source_ip = str(request.META["REMOTE_ADDR"]) + Statistics(source=source_ip, view=ViewType.COWRIE_SESSION_VIEW.value).save() + 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} From ef4750993fd4100e21978a0656bdf67f32421930 Mon Sep 17 00:00:00 2001 From: Swara Dalvi Date: Wed, 11 Mar 2026 22:30:23 +0530 Subject: [PATCH 055/109] Refactor: Reduce duplicated logic in chart components. Closes #969 (#990) * Refractor:introduced a function createAreaChart to remove duplicate logic * fix: updated chart tests * Update frontend/src/components/dashboard/utils/charts.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * WIP: save changes before rebase * jests error resolved * linter fix --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/dashboard/utils/charts.jsx | 162 +++++++----------- .../components/dashboard/charts.test.jsx | 98 +++++++++++ 2 files changed, 163 insertions(+), 97 deletions(-) create mode 100644 frontend/tests/components/dashboard/charts.test.jsx diff --git a/frontend/src/components/dashboard/utils/charts.jsx b/frontend/src/components/dashboard/utils/charts.jsx index 992b43a5..bda08cc0 100644 --- a/frontend/src/components/dashboard/utils/charts.jsx +++ b/frontend/src/components/dashboard/utils/charts.jsx @@ -32,109 +32,77 @@ const COUNTRY_BAR_COLOR = "#e05252"; // constants const colors = getRandomColorsArray(30, true); -export const FeedsSourcesChart = React.memo(() => { - console.debug("FeedsSourcesChart rendered!"); - - const chartProps = React.useMemo( - () => ({ - url: FEEDS_STATISTICS_SOURCES_URI, - accessorFnAggregation: (d) => d, - componentsFn: () => - Object.entries(FEED_COLOR_MAP) - .slice(0, 1) - .map(([dkey, color]) => ( - - )), - }), - [], - ); - - return ; -}); - -export const FeedsDownloadsChart = React.memo(() => { - console.debug("FeedsDownloadsChart rendered!"); - - const chartProps = React.useMemo( - () => ({ - url: FEEDS_STATISTICS_DOWNLOADS_URI, - accessorFnAggregation: (d) => d, - componentsFn: () => - Object.entries(FEED_COLOR_MAP) - .slice(1, 2) - .map(([dkey, color]) => ( - - )), - }), - [], - ); - - return ; -}); +/** + * Creates an area chart component to avoid duplicating chart setup code. + * + * @param {string} name - Display name for the generated chart component. + * @param {string} url - API endpoint used to fetch chart data. + * @param {Object} colorMap - Map of data keys to color values. + * @param {number} start - Start index for slicing the color map. + * @param {number} end - End index for slicing the color map. + */ +export const createAreaChart = (name, url, colorMap, start, end) => { + const Component = React.memo(() => { + console.debug(`${name} rendered!`); + + const chartProps = React.useMemo( + () => ({ + url, + accessorFnAggregation: (d) => d, + componentsFn: () => + Object.entries(colorMap) + .slice(start, end) + .map(([key, color]) => ( + + )), + }), + [url, colorMap, start, end], + ); -export const EnrichmentSourcesChart = React.memo(() => { - console.debug("EnrichmentSourcesChart rendered!"); + return ; + }); - const chartProps = React.useMemo( - () => ({ - url: ENRICHMENT_STATISTICS_SOURCES_URI, - accessorFnAggregation: (d) => d, - componentsFn: () => - Object.entries(ENRICHMENT_COLOR_MAP) - .slice(0, 1) - .map(([dkey, color]) => ( - - )), - }), - [], - ); + Component.displayName = name; - return ; -}); + return Component; +}; -export const EnrichmentRequestsChart = React.memo(() => { - console.debug("EnrichmentRequestsChart rendered!"); +export const FeedsSourcesChart = createAreaChart( + "FeedsSourcesChart", + FEEDS_STATISTICS_SOURCES_URI, + FEED_COLOR_MAP, + 0, + 1, +); - const chartProps = React.useMemo( - () => ({ - url: ENRICHMENT_STATISTICS_REQUESTS_URI, - accessorFnAggregation: (d) => d, - componentsFn: () => - Object.entries(ENRICHMENT_COLOR_MAP) - .slice(1, 2) - .map(([dkey, color]) => ( - - )), - }), - [], - ); +export const FeedsDownloadsChart = createAreaChart( + "FeedsDownloadsChart", + FEEDS_STATISTICS_DOWNLOADS_URI, + FEED_COLOR_MAP, + 1, + 2, +); - return ; -}); +export const EnrichmentSourcesChart = createAreaChart( + "EnrichmentSourcesChart", + ENRICHMENT_STATISTICS_SOURCES_URI, + ENRICHMENT_COLOR_MAP, + 0, + 1, +); +export const EnrichmentRequestsChart = createAreaChart( + "EnrichmentRequestsChart", + ENRICHMENT_STATISTICS_REQUESTS_URI, + ENRICHMENT_COLOR_MAP, + 1, + 2, +); export const FeedsTypesChart = React.memo(() => { console.debug("FeedsTypesChart rendered!"); diff --git a/frontend/tests/components/dashboard/charts.test.jsx b/frontend/tests/components/dashboard/charts.test.jsx new file mode 100644 index 00000000..9807ed38 --- /dev/null +++ b/frontend/tests/components/dashboard/charts.test.jsx @@ -0,0 +1,98 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; + +import { + FeedsSourcesChart, + FeedsDownloadsChart, + EnrichmentSourcesChart, + EnrichmentRequestsChart, + FeedsTypesChart, +} from "../../../src/components/dashboard/utils/charts"; + +import { + FEEDS_STATISTICS_SOURCES_URI, + FEEDS_STATISTICS_TYPES_URI, +} from "../../../src/constants/api"; + +import { AnyChartWidget } from "@certego/certego-ui"; + +// Mock recharts +vi.mock("recharts", () => ({ + Bar: ({ dataKey }) =>
, + Area: ({ dataKey }) =>
, +})); + +// Mock certego-ui +vi.mock("@certego/certego-ui", () => ({ + AnyChartWidget: vi.fn(({ url, componentsFn }) => { + const mockData = [{ date: "2024-01-01", feed1: 10, feed2: 20 }]; + + return ( +
+ {componentsFn && componentsFn(mockData)} +
+ ); + }), + getRandomColorsArray: vi.fn(() => ["#111111", "#222222", "#333333"]), +})); + +describe("Charts Components", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("createAreaChart sets correct displayName", () => { + expect(FeedsSourcesChart.displayName).toBe("FeedsSourcesChart"); + expect(FeedsDownloadsChart.displayName).toBe("FeedsDownloadsChart"); + expect(EnrichmentSourcesChart.displayName).toBe("EnrichmentSourcesChart"); + expect(EnrichmentRequestsChart.displayName).toBe("EnrichmentRequestsChart"); + }); + + test("charts pass correct props to AnyChartWidget", () => { + render(); + + expect(AnyChartWidget).toHaveBeenCalled(); + + const props = AnyChartWidget.mock.calls[0][0]; + + expect(props).toEqual( + expect.objectContaining({ + url: FEEDS_STATISTICS_SOURCES_URI, + accessorFnAggregation: expect.any(Function), + componentsFn: expect.any(Function), + }), + ); + }); + + test("charts render Area components", () => { + render(); + + const areas = screen.getAllByTestId(/^area-/); + expect(areas.length).toBeGreaterThan(0); + }); + + test("FeedsTypesChart passes correct props to AnyChartWidget", () => { + render(); + + expect(AnyChartWidget).toHaveBeenCalled(); + + const props = AnyChartWidget.mock.calls[0][0]; + + expect(props).toEqual( + expect.objectContaining({ + url: FEEDS_STATISTICS_TYPES_URI, + accessorFnAggregation: expect.any(Function), + componentsFn: expect.any(Function), + }), + ); + }); + + test("FeedsTypesChart renders Bar components from response data", () => { + render(); + + const bars = screen.getAllByTestId(/^bar-/); + expect(bars.length).toBeGreaterThan(0); + }); +}); From 870ff77a1b667ca05d16a1baf8251cf3a5e6d983 Mon Sep 17 00:00:00 2001 From: Rahul Guwani Date: Thu, 12 Mar 2026 01:13:06 +0530 Subject: [PATCH 056/109] feat: allow querying CowrieSession API by password. Closes #607 (#1022) * feat: allow querying CowrieSession API by password. Closes #607 * fix: add password length validation and additional tests for password query --------- Co-authored-by: rahul-software-dev <24f3003169@ds.study.iitm.ac.in> --- api/views/cowrie_session.py | 16 +++-- tests/api/views/test_cowrie_session_view.py | 68 +++++++++++++++++---- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index 6c1641bb..ad872327 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -28,13 +28,14 @@ def cowrie_session_view(request): """ Retrieve Cowrie honeypot session data including command sequences, credentials, and session details. - Queries can be performed using either an IP address to find all sessions from that source, - or a SHA-256 hash to find sessions containing a specific command sequence. + Queries can be performed using an IP address to find all sessions from that source, + a SHA-256 hash to find sessions containing a specific command sequence, + or a password to find all sessions where that password was used. Args: request: The HTTP request object containing query parameters - query (str, required): The search term, can be either an IP address or the SHA-256 hash of a command sequence. - SHA-256 hashes should match command sequences generated using Python's "\\n".join(sequence) format. + query (str, required): The search term, can be an IP address, the SHA-256 hash of a command sequence, + or a password. SHA-256 hashes should match command sequences generated using Python's "\\n".join(sequence) format. include_similar (bool, optional): When "true", expands the result to include all sessions that executed command sequences belonging to the same cluster(s) as command sequences found in the initial query result. Requires CLUSTER_COWRIE_COMMAND_SEQUENCES enabled in configuration. Default: false @@ -64,6 +65,7 @@ def cowrie_session_view(request): /api/cowrie_session?query=1.2.3.4 /api/cowrie_session?query=5120e94e366ec83a79ee80454e4d1c76c06499ab19032bcdc7f0b4523bdb37a6 /api/cowrie_session?query=1.2.3.4&include_credentials=true&include_session_data=true&include_similar=true + /api/cowrie_session?query=admin123 """ observable = request.query_params.get("query") include_similar = request.query_params.get("include_similar", "false").lower() == "true" @@ -87,7 +89,11 @@ def cowrie_session_view(request): raise Http404(f"No command sequences found with hash: {observable}") from exc sessions = CowrieSession.objects.filter(commands=commands, duration__gt=0).prefetch_related("source", "commands", "credentials") else: - return HttpResponseBadRequest("Query must be a valid IP address or SHA-256 hash") + if len(observable) > 256: # max_length of Credential.password field + return HttpResponseBadRequest("Query exceeds maximum password length") + sessions = CowrieSession.objects.filter(credentials__password=observable, duration__gt=0).prefetch_related("source", "commands", "credentials") + if not sessions.exists(): + raise Http404(f"No information found for password: {observable}") source_ip = str(request.META["REMOTE_ADDR"]) Statistics(source=source_ip, view=ViewType.COWRIE_SESSION_VIEW.value).save() diff --git a/tests/api/views/test_cowrie_session_view.py b/tests/api/views/test_cowrie_session_view.py index e7d01bed..5d08a6ad 100644 --- a/tests/api/views/test_cowrie_session_view.py +++ b/tests/api/views/test_cowrie_session_view.py @@ -107,14 +107,14 @@ def test_ipv6_address_query(self): self.assertEqual(response.status_code, 404) def test_invalid_ip_format(self): - """Test that malformed IP addresses are rejected.""" + """Test that malformed IP addresses are treated as password lookups.""" response = self.client.get("/api/cowrie_session?query=999.999.999.999") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 404) def test_ip_with_cidr_notation(self): - """Test that CIDR notation is rejected.""" + """Test that CIDR notation is treated as a password lookup.""" response = self.client.get("/api/cowrie_session?query=192.168.1.0/24") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 404) # # # # # Parameter Validation Tests # # # # # def test_missing_query_parameter(self): @@ -123,9 +123,9 @@ def test_missing_query_parameter(self): self.assertEqual(response.status_code, 400) def test_invalid_query_parameter(self): - """Test that view returns BadRequest when query parameter is invalid.""" + """Test that non-IP, non-hash queries are treated as password lookups.""" response = self.client.get("/api/cowrie_session?query=invalid-input}") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 404) def test_include_credentials_invalid_value(self): """Test that invalid boolean values default to false.""" @@ -150,15 +150,15 @@ def test_nonexistent_hash(self): self.assertEqual(response.status_code, 404) def test_hash_wrong_length(self): - """Test that hashes with incorrect length are rejected.""" + """Test that strings with incorrect hash length are treated as password lookups.""" response = self.client.get("/api/cowrie_session?query=" + "a" * 32) # 32 chars instead of 64 - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 404) def test_hash_invalid_characters(self): - """Test that hashes with invalid characters are rejected.""" + """Test that strings with invalid hash characters are treated as password lookups.""" invalid_hash = "g" * 64 # 'g' is not a valid hex character response = self.client.get(f"/api/cowrie_session?query={invalid_hash}") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 404) def test_hash_case_insensitive(self): """Test that hash queries are case-insensitive.""" @@ -205,9 +205,9 @@ def test_hash_query_without_license(self): self.assertNotIn("license", response.data) def test_query_with_special_characters(self): - """Test handling of queries with special characters.""" + """Test that queries with special characters are treated as password lookups.""" response = self.client.get("/api/cowrie_session?query=") - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 404) # # # # # Authentication & Authorization Tests # # # # # def test_unauthenticated_request(self): @@ -222,3 +222,47 @@ def test_regular_user_access(self): client.force_authenticate(user=self.regular_user) response = client.get("/api/cowrie_session?query=140.246.171.141") self.assertEqual(response.status_code, 200) + + # # # # # Password Query Tests # # # # # + def test_password_query(self): + """Test view with a valid password query.""" + response = self.client.get("/api/cowrie_session?query=root") + self.assertEqual(response.status_code, 200) + self.assertIn("query", response.data) + self.assertIn("commands", response.data) + self.assertIn("sources", response.data) + self.assertNotIn("credentials", response.data) + self.assertNotIn("sessions", response.data) + + def test_password_query_with_credentials(self): + """Test password query including credentials.""" + response = self.client.get("/api/cowrie_session?query=root&include_credentials=true") + self.assertEqual(response.status_code, 200) + self.assertIn("credentials", response.data) + self.assertEqual(response.data["credentials"][0], "root | root") + + def test_nonexistent_password(self): + """Test that view returns 404 for password with no matching sessions.""" + response = self.client.get("/api/cowrie_session?query=nonexistentpassword123") + self.assertEqual(response.status_code, 404) + + def test_password_query_with_similar(self): + """Test password query including similar sessions.""" + response = self.client.get("/api/cowrie_session?query=root&include_similar=true") + self.assertEqual(response.status_code, 200) + self.assertIn("query", response.data) + self.assertIn("commands", response.data) + self.assertIn("sources", response.data) + + def test_password_query_with_session_data(self): + """Test password query including session data.""" + response = self.client.get("/api/cowrie_session?query=root&include_session_data=true") + self.assertEqual(response.status_code, 200) + self.assertIn("sessions", response.data) + self.assertEqual(response.data["sessions"][0]["source"], "140.246.171.141") + self.assertEqual(response.data["sessions"][0]["credentials"][0], "root | root") + + def test_password_too_long(self): + """Test that passwords exceeding max length return 400.""" + response = self.client.get(f"/api/cowrie_session?query={'a' * 257}") + self.assertEqual(response.status_code, 400) From 97ac79a12313195ddc3194126840bf17f5f8e3bc Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Thu, 12 Mar 2026 21:45:26 +0545 Subject: [PATCH 057/109] feat(pipeline/api): replace IOC.asn with AutonomousSystem FK. Closes #770 (#947) * feat(pipeline/api): replace IOC.asn with AutonomousSystem FK Signed-off-by: Drona Raj Gyawali * resolved migration conflict Signed-off-by: Drona Raj Gyawali * chores: optimized migration code for prod Signed-off-by: Drona Raj Gyawali * refactor migration testcase and solved asn error Signed-off-by: Drona Raj Gyawali * solved linter issue Signed-off-by: Drona Raj Gyawali * added simple cache to asn & refactor code * chores: rename repo name * preloaded cache and changed file name --------- Signed-off-by: Drona Raj Gyawali --- api/serializers.py | 1 + api/views/utils.py | 20 +++-- greedybear/admin.py | 21 ++++- .../cronjobs/extraction/ioc_processor.py | 5 +- greedybear/cronjobs/extraction/utils.py | 8 +- greedybear/cronjobs/repositories/__init__.py | 1 + .../repositories/autonomous_system.py | 40 +++++++++ greedybear/cronjobs/repositories/sensor.py | 3 +- ...utonomoussystem_remove_ioc_asn_and_more.py | 61 ++++++++++++++ greedybear/models.py | 16 +++- tests/__init__.py | 18 +++-- tests/api/views/test_feeds_advanced_view.py | 8 +- tests/api/views/test_feeds_asn_view.py | 54 +++++++++++-- tests/test_as_repo.py | 81 +++++++++++++++++++ tests/test_extraction_utils.py | 31 ++++++- tests/test_ioc_processor.py | 4 +- tests/test_migrations.py | 68 ++++++++++++++++ tests/test_models.py | 2 +- 18 files changed, 404 insertions(+), 38 deletions(-) create mode 100644 greedybear/cronjobs/repositories/autonomous_system.py create mode 100644 greedybear/migrations/0043_autonomoussystem_remove_ioc_asn_and_more.py create mode 100644 tests/test_as_repo.py diff --git a/api/serializers.py b/api/serializers.py index 5eb7f53e..55c3754d 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -163,6 +163,7 @@ class ASNFeedsOrderingSerializer(FeedsRequestSerializer): ALLOWED_ORDERING_FIELDS = frozenset( { "asn", + "as_name", "ioc_count", "total_attack_count", "total_interaction_count", diff --git a/api/views/utils.py b/api/views/utils.py index 7c14f2d6..3eb53c20 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -193,7 +193,7 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se # Advanced filters if feed_params.asn: - query_dict["asn"] = feed_params.asn + query_dict["autonomous_system__asn"] = feed_params.asn if feed_params.min_score is not None: query_dict["recurrence_probability__gte"] = feed_params.min_score if feed_params.port: @@ -311,13 +311,13 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N "scanner", "payload_request", "ip_reputation", - "asn", "login_attempts", "recurrence_probability", "expected_interactions", "honeypots", # used to build feed_type; removed from response "destination_ports", # used to calculate destination_port_count "attacker_country", + "autonomous_system", "tags", ) @@ -350,12 +350,13 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N "last_seen": ioc["last_seen"].strftime("%Y-%m-%d"), "feed_type": ioc_feed_type, "destination_port_count": len(ioc.get("destination_ports", [])), + "asn": ioc.get("autonomous_system", ""), "tags": ioc.pop("tags_json", []), } if not verbose: data_.pop("destination_ports", None) - + data_.pop("autonomous_system", None) data_.pop("honeypots", None) data_.pop("id", None) @@ -455,7 +456,7 @@ def asn_aggregated_queryset(iocs_qs, request, feed_params): """ asn_filter = request.query_params.get("asn") if asn_filter: - iocs_qs = iocs_qs.filter(asn=asn_filter) + iocs_qs = iocs_qs.filter(autonomous_system__asn=asn_filter) # default ordering is overridden here because of serializer default(-last-seen) behaviour ordering = feed_params.ordering @@ -463,8 +464,11 @@ def asn_aggregated_queryset(iocs_qs, request, feed_params): ordering = "-ioc_count" numeric_agg = ( - iocs_qs.exclude(asn__isnull=True) - .values("asn") + iocs_qs.exclude(autonomous_system__isnull=True) + .values( + asn=F("autonomous_system__asn"), + as_name=F("autonomous_system__name"), + ) .annotate( ioc_count=Count("id"), total_attack_count=Sum("attack_count"), @@ -479,9 +483,9 @@ def asn_aggregated_queryset(iocs_qs, request, feed_params): ) honeypot_agg = ( - iocs_qs.exclude(asn__isnull=True) + iocs_qs.exclude(autonomous_system__isnull=True) .filter(general_honeypot__active=True) - .values("asn") + .values(asn=F("autonomous_system__asn")) .annotate( honeypots=ArrayAgg( "general_honeypot__name", diff --git a/greedybear/admin.py b/greedybear/admin.py index 0ba94e64..0ac18f17 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -141,7 +141,7 @@ class IOCModelAdmin(admin.ModelAdmin): "sensor_list", "ip_reputation", "firehol_categories", - "asn", + "autonomous_system_display", "destination_ports", "login_attempts", ] @@ -150,7 +150,7 @@ class IOCModelAdmin(admin.ModelAdmin): "scanner", "payload_request", "ip_reputation", - "asn", + "autonomous_system", ] search_fields = ["name", "related_ioc__name"] search_help_text = ["search for the IP address source"] @@ -164,9 +164,22 @@ def general_honeypots(self, ioc): def sensor_list(self, ioc): return ", ".join([str(sensor.address) for sensor in ioc.sensors.all()]) + def autonomous_system_display(self, ioc): + """ + Shows ASN and AS name neatly in list_display. + """ + if ioc.autonomous_system: + asn = ioc.autonomous_system.asn + name = ioc.autonomous_system.name + return f"{asn} ({name})" if name else str(asn) + return "-" + + autonomous_system_display.short_description = "Autonomous System" + autonomous_system_display.admin_order_field = "autonomous_system__asn" + def get_queryset(self, request): - """Override to prefetch related sensors and honeypots, avoiding N+1 queries.""" - return super().get_queryset(request).prefetch_related("sensors", "general_honeypot") + """Override to optimize queries and avoid N+1 problems.""" + return super().get_queryset(request).select_related("autonomous_system").prefetch_related("sensors", "general_honeypot") @admin.register(GeneralHoneypot) diff --git a/greedybear/cronjobs/extraction/ioc_processor.py b/greedybear/cronjobs/extraction/ioc_processor.py index f909b306..13cf4c79 100644 --- a/greedybear/cronjobs/extraction/ioc_processor.py +++ b/greedybear/cronjobs/extraction/ioc_processor.py @@ -100,10 +100,13 @@ def _merge_iocs(self, existing: IOC, new: IOC) -> IOC: existing.related_urls = sorted(set(existing.related_urls + new.related_urls)) existing.destination_ports = sorted(set(existing.destination_ports + new.destination_ports)) existing.ip_reputation = existing.ip_reputation or new.ip_reputation - existing.asn = new.asn existing.firehol_categories = list(new.firehol_categories) existing.login_attempts += new.login_attempts + # updating autonomous_system fk + if new.autonomous_system: + existing.autonomous_system = new.autonomous_system + # we will always update attacker_country if incoming value exists if new.attacker_country: existing.attacker_country = new.attacker_country diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index 786f9c9d..a0af4fcc 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -8,6 +8,7 @@ from django.conf import settings from greedybear.consts import DOMAIN, IP +from greedybear.cronjobs.repositories import ASRepository from greedybear.models import IOC, FireHolList, MassScanner, WhatsMyIPDomain @@ -119,6 +120,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: for hit in hits: hits_by_ip[hit["src_ip"]].append(hit) iocs = [] + 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: @@ -148,12 +150,16 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: geoip = hits[0].get("geoip", {}) if hits else {} attacker_country = geoip.get("country_name", "") + asn = geoip.get("asn") + as_name = geoip.get("as_org", "") + autonomous_system = as_repository.get_or_create(asn, as_name) if asn else None + ioc = IOC( name=ip, type=get_ioc_type(ip), interaction_count=len(hits), ip_reputation=correct_ip_reputation(ip, hits[0].get("ip_rep", "")), - asn=hits[0].get("geoip", {}).get("asn"), + autonomous_system=autonomous_system, destination_ports=sorted(set(dest_ports)), login_attempts=login_attempts, firehol_categories=firehol_categories, diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py index 9773eb33..337eac0c 100644 --- a/greedybear/cronjobs/repositories/__init__.py +++ b/greedybear/cronjobs/repositories/__init__.py @@ -1,3 +1,4 @@ +from greedybear.cronjobs.repositories.autonomous_system import * from greedybear.cronjobs.repositories.cowrie_session import * from greedybear.cronjobs.repositories.elastic import * from greedybear.cronjobs.repositories.firehol import * diff --git a/greedybear/cronjobs/repositories/autonomous_system.py b/greedybear/cronjobs/repositories/autonomous_system.py new file mode 100644 index 00000000..e8c18e93 --- /dev/null +++ b/greedybear/cronjobs/repositories/autonomous_system.py @@ -0,0 +1,40 @@ +import logging + +from greedybear.models import AutonomousSystem + + +class ASRepository: + """Repository to handle AutonomousSystem objects with caching.""" + + def __init__(self): + self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + self._cache = {as_obj.asn: as_obj for as_obj in AutonomousSystem.objects.all()} + self.log.info(f"Preloaded {len(self._cache)} ASs into cache") + + def get_or_create(self, asn: int, name: str) -> AutonomousSystem: + """ + Get or create an AutonomousSystem by ASN with in-memory cache. + + If AS exists but name is missing and a new name is provided, update it. + + Args: + asn: Autonomous System Number + name: Name of the AS + + Returns: + AutonomousSystem instance + """ + if asn in self._cache: + return self._cache[asn] + + as_obj, created = AutonomousSystem.objects.get_or_create(asn=asn, defaults={"name": name or ""}) + + if created: + self.log.info(f"Created new AS {asn} with name '{name}'") + elif not as_obj.name and name: + as_obj.name = name + as_obj.save(update_fields=["name"]) + self.log.info(f"Updated AS {asn} name to '{name}'") + + self._cache[asn] = as_obj + return as_obj diff --git a/greedybear/cronjobs/repositories/sensor.py b/greedybear/cronjobs/repositories/sensor.py index efc51b76..543e7ec5 100644 --- a/greedybear/cronjobs/repositories/sensor.py +++ b/greedybear/cronjobs/repositories/sensor.py @@ -1,7 +1,6 @@ import logging from greedybear.consts import IP -from greedybear.cronjobs.extraction.utils import get_ioc_type from greedybear.models import Sensor @@ -31,6 +30,8 @@ def get_or_create_sensor(self, ip: str) -> Sensor | None: Returns: Sensor object if valid, None if invalid IP format. """ + from greedybear.cronjobs.extraction.utils import get_ioc_type + if ip in self.cache: return self.cache[ip] if get_ioc_type(ip) != IP: diff --git a/greedybear/migrations/0043_autonomoussystem_remove_ioc_asn_and_more.py b/greedybear/migrations/0043_autonomoussystem_remove_ioc_asn_and_more.py new file mode 100644 index 00000000..25882422 --- /dev/null +++ b/greedybear/migrations/0043_autonomoussystem_remove_ioc_asn_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.11 on 2026-03-03 09:27 + +from django.db import migrations, models +from django.db.models import F +import django.db.models.deletion + + +def migrate_asn_to_autonomous_system(apps, schema_editor): + IOC = apps.get_model("greedybear", "IOC") + AutonomousSystem = apps.get_model("greedybear", "AutonomousSystem") + + asns = ( + IOC.objects.exclude(asn__isnull=True) + .values_list("asn", flat=True) + .distinct() + ) + + AutonomousSystem.objects.bulk_create( + [AutonomousSystem(asn=asn, name="") for asn in asns], + ignore_conflicts=True, # prevent duplicate asn without crashing + ) + + IOC.objects.exclude(asn__isnull=True).update( + autonomous_system_id=F("asn") + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("greedybear", "0042_credential_model_and_data_migration"), + ] + + operations = [ + migrations.CreateModel( + name="AutonomousSystem", + fields=[ + ("asn", models.IntegerField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=256, blank=True)), + ], + ), + migrations.AddField( + model_name="ioc", + name="autonomous_system", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="iocs", + to="greedybear.autonomoussystem", + ), + ), + migrations.RunPython( + migrate_asn_to_autonomous_system, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="ioc", + name="asn", + ), + ] \ No newline at end of file diff --git a/greedybear/models.py b/greedybear/models.py index b9d3a101..e25d8266 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -54,6 +54,14 @@ def __str__(self): return f"{self.ip_address} ({self.source or 'unknown'})" +class AutonomousSystem(models.Model): + asn = models.IntegerField(primary_key=True) + name = models.CharField(max_length=256, blank=True) + + def __str__(self): + return f"{self.name} ({self.asn})" if self.name else str(self.asn) + + class IOC(models.Model): name = models.CharField(max_length=256) type = models.CharField(max_length=32, choices=IocType.choices) @@ -68,6 +76,13 @@ class IOC(models.Model): blank=True, default="", ) + autonomous_system = models.ForeignKey( + AutonomousSystem, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="iocs", + ) # FEEDS - list of honeypots from general list, from which the IOC was detected general_honeypot = models.ManyToManyField(GeneralHoneypot, blank=True) # SENSORS - list of T-Pot sensors that detected this IOC @@ -78,7 +93,6 @@ class IOC(models.Model): related_urls = pg_fields.ArrayField(models.CharField(max_length=900, blank=True), blank=True, default=list) ip_reputation = models.CharField(max_length=32, blank=True) firehol_categories = pg_fields.ArrayField(models.CharField(max_length=64, blank=True), blank=True, default=list) - asn = models.IntegerField(blank=True, null=True) destination_ports = pg_fields.ArrayField(models.IntegerField(), default=list) login_attempts = models.IntegerField(default=0) # SCORES diff --git a/tests/__init__.py b/tests/__init__.py index 798facc1..90f5c0cf 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -8,6 +8,7 @@ from greedybear.models import ( IOC, + AutonomousSystem, CommandSequence, CowrieSession, Credential, @@ -21,6 +22,7 @@ class CustomTestCase(TestCase): def setUpTestData(cls): super().setUpTestData() + cls.as_obj, _ = AutonomousSystem.objects.get_or_create(asn="12345", defaults={"name": "greedybear"}) cls.heralding = GeneralHoneypot.objects.get_or_create(name="Heralding", defaults={"active": True})[0] cls.ciscoasa = GeneralHoneypot.objects.get_or_create(name="Ciscoasa", defaults={"active": True})[0] cls.ddospot = GeneralHoneypot.objects.get_or_create(name="Ddospot", defaults={"active": False})[0] @@ -44,7 +46,7 @@ def setUpTestData(cls): payload_request=True, related_urls=[], ip_reputation="", - asn="12345", + autonomous_system=cls.as_obj, destination_ports=[22, 23, 24], login_attempts=1, recurrence_probability=0.1, @@ -65,7 +67,7 @@ def setUpTestData(cls): payload_request=True, related_urls=[], ip_reputation="mass scanner", - asn="12345", + autonomous_system=cls.as_obj, destination_ports=[22, 23, 24], login_attempts=1, recurrence_probability=0.1, @@ -86,7 +88,7 @@ def setUpTestData(cls): payload_request=True, related_urls=[], ip_reputation="tor exit node", - asn="12345", + autonomous_system=cls.as_obj, destination_ports=[22, 23, 24], login_attempts=1, recurrence_probability=0.1, @@ -107,7 +109,7 @@ def setUpTestData(cls): payload_request=True, related_urls=[], ip_reputation="", - asn=None, + autonomous_system=None, destination_ports=[], login_attempts=0, recurrence_probability=0.2, @@ -241,9 +243,15 @@ def _create_mock_ioc( mock.first_seen = first_seen if first_seen is not None else datetime.now() mock.last_seen = last_seen if last_seen is not None else datetime.now() mock.ip_reputation = ip_reputation - mock.asn = asn mock.firehol_categories = firehol_categories if firehol_categories is not None else [] mock.number_of_days_seen = len(mock.days_seen) + + if asn is not None: + mock.autonomous_system = Mock() + mock.autonomous_system.asn = asn + else: + mock.autonomous_system = None + return mock diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index bade733f..7d0b50f8 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -3,7 +3,7 @@ from django.conf import settings from rest_framework.test import APIClient -from greedybear.models import IOC, IocType +from greedybear.models import IOC, AutonomousSystem, IocType from tests import CustomTestCase @@ -107,8 +107,10 @@ def setUp(self): self.client = APIClient() self.client.force_authenticate(user=self.superuser) + as_obj1, _ = AutonomousSystem.objects.get_or_create(asn=11111, defaults={"name": ""}) + as_obj2, _ = AutonomousSystem.objects.get_or_create(asn=22222, defaults={"name": ""}) + self.ioc.autonomous_system = as_obj1 # Give the base IOC unique values to isolate filter tests - self.ioc.asn = 11111 self.ioc.destination_ports = [9001, 9002] self.ioc.recurrence_probability = 0.8 self.ioc.save() @@ -125,7 +127,7 @@ def setUp(self): first_seen=datetime.now() - timedelta(days=1), last_seen=datetime.now(), recurrence_probability=0.2, - asn=22222, + autonomous_system=as_obj2, destination_ports=[9003], attack_count=1, interaction_count=1, diff --git a/tests/api/views/test_feeds_asn_view.py b/tests/api/views/test_feeds_asn_view.py index c50557f5..e6eace5c 100644 --- a/tests/api/views/test_feeds_asn_view.py +++ b/tests/api/views/test_feeds_asn_view.py @@ -1,7 +1,7 @@ from django.utils import timezone from rest_framework.test import APIClient -from greedybear.models import IOC, GeneralHoneypot +from greedybear.models import IOC, AutonomousSystem, GeneralHoneypot from tests import CustomTestCase @@ -18,10 +18,14 @@ def setUpClass(cls): cls.high_asn = "13335" cls.low_asn = "16276" + cls.as_high = AutonomousSystem.objects.create(asn=int(cls.high_asn), name="CLOUDFLARE") + + cls.as_low = AutonomousSystem.objects.create(asn=int(cls.low_asn), name="OVH") + cls.ioc_high1 = IOC.objects.create( name="high1.example.com", type="ip", - asn=cls.high_asn, + autonomous_system=cls.as_high, attack_count=15, interaction_count=30, login_attempts=5, @@ -30,12 +34,11 @@ def setUpClass(cls): expected_interactions=20.0, ) cls.ioc_high1.general_honeypot.add(cls.testpot1, cls.testpot2) - cls.ioc_high1.save() cls.ioc_high2 = IOC.objects.create( name="high2.example.com", type="ip", - asn=cls.high_asn, + autonomous_system=cls.as_high, attack_count=5, interaction_count=10, login_attempts=2, @@ -44,12 +47,11 @@ def setUpClass(cls): expected_interactions=8.0, ) cls.ioc_high2.general_honeypot.add(cls.testpot1, cls.testpot2) - cls.ioc_high2.save() cls.ioc_low = IOC.objects.create( name="low.example.com", type="ip", - asn=cls.low_asn, + autonomous_system=cls.as_low, attack_count=2, interaction_count=5, login_attempts=1, @@ -58,7 +60,6 @@ def setUpClass(cls): expected_interactions=3.0, ) cls.ioc_low.general_honeypot.add(cls.testpot1, cls.testpot2) - cls.ioc_low.save() def setUp(self): super().setUp() @@ -82,7 +83,7 @@ def test_200_asn_feed_aggregated_fields(self): self.assertIsNotNone(high_item) # getting all IOCs for high ASN from the DB - high_iocs = IOC.objects.filter(asn=self.high_asn) + high_iocs = IOC.objects.filter(autonomous_system__asn=self.high_asn) self.assertEqual(high_item["ioc_count"], high_iocs.count()) self.assertEqual(high_item["total_attack_count"], sum(i.attack_count for i in high_iocs)) @@ -183,3 +184,40 @@ def test_asn_feed_ignores_feed_size(self): results = response.json() # aggregation should return all ASNs regardless of feed_size self.assertEqual(len(results), 2) + + def test_asn_feed_includes_as_name(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + + high_item = next((item for item in results if str(item["asn"]) == self.high_asn), None) + + self.assertIsNotNone(high_item) + self.assertIn("as_name", high_item) + self.assertEqual(high_item["as_name"], "CLOUDFLARE") + + def test_asn_feed_with_empty_as_name(self): + """ + Ensure ASN feed works even if the AS name is empty, + """ + # Temporarily blank the name of the low AS + original_name = self.as_low.name + self.as_low.name = "" + self.as_low.save(update_fields=["name"]) + + try: + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + + # Find the low ASN in results + item = next((r for r in results if str(r["asn"]) == self.low_asn), None) + self.assertIsNotNone(item) + + # as_name should exist and be blank + self.assertIn("as_name", item) + self.assertEqual(item["as_name"], "") + finally: + # Restore original name + self.as_low.name = original_name + self.as_low.save(update_fields=["name"]) diff --git a/tests/test_as_repo.py b/tests/test_as_repo.py new file mode 100644 index 00000000..932d9037 --- /dev/null +++ b/tests/test_as_repo.py @@ -0,0 +1,81 @@ +from greedybear.cronjobs.repositories import ASRepository +from greedybear.models import AutonomousSystem +from tests import CustomTestCase + + +class ASRepositoryTestCase(CustomTestCase): + def setUp(self): + super().setUp() + self.repo = ASRepository() + + def test_create_new_asn(self): + """Test that a new ASN is created with the given name.""" + asn_number = 64500 + as_name = "GB" + + as_obj = self.repo.get_or_create(asn_number, as_name) + + self.assertIsInstance(as_obj, AutonomousSystem) + self.assertEqual(as_obj.asn, asn_number) + self.assertEqual(as_obj.name, as_name) + + def test_create_new_asn_with_empty_name(self): + """Test that a new ASN is created with empty string if name is None or blank.""" + asn_number = 64501 + + as_obj = self.repo.get_or_create(asn_number, None) + self.assertEqual(as_obj.name, "") + + as_obj2 = self.repo.get_or_create(asn_number + 1, "") + self.assertEqual(as_obj2.name, "") + + def test_get_existing_asn_without_updating_name(self): + """Existing ASN with a name should not be updated if a different name is passed.""" + asn_number = 64502 + AutonomousSystem.objects.create(asn=asn_number, name="GB") + + as_obj = self.repo.get_or_create(asn_number, "GB") + self.assertEqual(as_obj.name, "GB") + + def test_update_existing_asn_with_missing_name(self): + """Existing ASN with empty name should be updated when a new name is provided.""" + asn_number = 64503 + AutonomousSystem.objects.create(asn=asn_number, name="") + + as_obj = self.repo.get_or_create(asn_number, "intelowl/@GB") + self.assertEqual(as_obj.name, "intelowl/@GB") + + def test_logging_on_create_and_update(self): + """Ensure the logger logs info messages when creating or updating ASNs.""" + asn_number = 64504 + as_name = "LogTest" + + with self.assertLogs(self.repo.log, level="INFO") as log_cm: + # Creation + self.repo.get_or_create(asn_number, as_name) + self.assertTrue(any("Created new AS" in msg for msg in log_cm.output)) + + with self.assertLogs(self.repo.log, level="INFO") as log_cm2: + # Update + AutonomousSystem.objects.create(asn=64505, name="") + self.repo.get_or_create(64505, "UpdatedName") + self.assertTrue(any("Updated AS" in msg for msg in log_cm2.output)) + + def test_cache_usage(self): + """Ensure that ASNRepository uses its internal cache to avoid duplicate DB hits.""" + asn_number = 64506 + as_name = "CacheTest" + + # First call creates the ASN + as_obj1 = self.repo.get_or_create(asn_number, as_name) + self.assertEqual(as_obj1.name, as_name) + + # Directly modify the DB to simulate change + AutonomousSystem.objects.filter(asn=asn_number).update(name="ModifiedName") + + # Second call should return cached object, not the DB modified one + as_obj2 = self.repo.get_or_create(asn_number, None) + self.assertEqual(as_obj2.name, as_name) # cache still has old name + + # The objects should be the same instance if cached internally + self.assertEqual(as_obj1.asn, as_obj2.asn) diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index 80b43b8a..eb70e7c2 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -408,13 +408,13 @@ def test_extracts_asn_from_geoip(self): hits = [self._create_hit(src_ip="8.8.8.8", asn=15169)] iocs = iocs_from_hits(hits) ioc = iocs[0] - self.assertEqual(ioc.asn, 15169) + self.assertEqual(ioc.autonomous_system.asn, 15169) def test_handles_missing_geoip(self): hits = [{"src_ip": "8.8.8.8", "@timestamp": "2025-01-01T12:00:00.000Z"}] iocs = iocs_from_hits(hits) ioc = iocs[0] - self.assertIsNone(ioc.asn) + self.assertIsNone(ioc.autonomous_system) def test_extracts_timestamps(self): hits = [ @@ -671,6 +671,33 @@ def test_ioc_attacker_country_set_correctly(self): self.assertEqual(ioc.interaction_count, 1) + def test_ioc_autonomous_system_set_correctly(self): + """Verify that iocs_from_hits sets autonomous_system FK correctly from hits.""" + + hits = [ + self._create_hit( + src_ip="8.8.8.8", + dest_port=80, + hit_type="Cowrie", + ) + ] + + # Manually injecting the geoip info to simulate AS enrichment + hits[0]["geoip"] = {"asn": 2945, "as_org": "greedybear", "country_name": "Nepal"} + + iocs = iocs_from_hits(hits) + self.assertEqual(len(iocs), 1) + + ioc = iocs[0] + + # Verify autonomous_system is properly created + self.assertIsNotNone(ioc.autonomous_system) + self.assertEqual(ioc.autonomous_system.asn, 2945) + self.assertEqual(ioc.autonomous_system.name, "greedybear") + + # Also check attacker_country is still set correctly + self.assertEqual(ioc.attacker_country, "Nepal") + class ThreatfoxSubmissionTestCase(ExtractionTestCase): def setUp(self): diff --git a/tests/test_ioc_processor.py b/tests/test_ioc_processor.py index 9d0d3667..20e253b3 100644 --- a/tests/test_ioc_processor.py +++ b/tests/test_ioc_processor.py @@ -237,13 +237,11 @@ def test_updating(self): new_time = datetime(2025, 1, 2, 12, 0, 0) existing = self._create_mock_ioc(first_seen=old_time, last_seen=old_time, ip_reputation="old", asn=12) new = self._create_mock_ioc(first_seen=new_time, last_seen=new_time, ip_reputation="new", asn=23) - result = self.processor._merge_iocs(existing, new) - self.assertEqual(result.last_seen, new_time) + self.assertEqual(result.autonomous_system.asn, 23) self.assertEqual(result.first_seen, old_time) self.assertEqual(result.ip_reputation, "old") - self.assertEqual(result.asn, 23) def test_last_seen_not_regressed(self): later = datetime(2025, 1, 2, 12, 0, 0) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 7553cbc6..645d20b8 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -121,6 +121,74 @@ def test_log4pot_kept_if_has_iocs(self): ) +@tag("migration") +class TestIocAsnToAutonomousSystem(MigrationTestCase): + """Tests migration from IOC.asn -> IOC.autonomous_system.""" + + migrate_from = "0042_credential_model_and_data_migration" + migrate_to = "0043_autonomoussystem_remove_ioc_asn_and_more" + + def test_asn_migrated_to_autonomous_system(self): + ioc_old = self.old_state.apps.get_model(self.app_name, "IOC") + + ioc1 = ioc_old.objects.create(asn=12345) + ioc2 = ioc_old.objects.create(asn=67890) + ioc3 = ioc_old.objects.create(asn=None) + + # Apply migration + new_state = self.apply_tested_migration() + ioc_new = new_state.apps.get_model(self.app_name, "IOC") + as_new = new_state.apps.get_model(self.app_name, "AutonomousSystem") + + ioc1_new = ioc_new.objects.get(pk=ioc1.pk) + ioc2_new = ioc_new.objects.get(pk=ioc2.pk) + ioc3_new = ioc_new.objects.get(pk=ioc3.pk) + + self.assertIsNotNone(ioc1_new.autonomous_system) + self.assertEqual(ioc1_new.autonomous_system.asn, 12345) + + self.assertIsNotNone(ioc2_new.autonomous_system) + self.assertEqual(ioc2_new.autonomous_system.asn, 67890) + + self.assertIsNone(ioc3_new.autonomous_system) + + self.assertEqual(as_new.objects.count(), 2) + asns = set(as_new.objects.values_list("asn", flat=True)) + self.assertSetEqual(asns, {12345, 67890}) + + def test_duplicate_asns_with_different_names(self): + """Ensure migration does not duplicate ASNs.""" + ioc_old = self.old_state.apps.get_model(self.app_name, "IOC") + + ioc_old.objects.create(asn=12345) + ioc_old.objects.create(asn=12345) + + new_state = self.apply_tested_migration() + as_new = new_state.apps.get_model(self.app_name, "AutonomousSystem") + + self.assertEqual(as_new.objects.count(), 1) + + def test_large_number_of_iocs(self): + """Ensure migration works correctly for many IOCs.""" + ioc_old = self.old_state.apps.get_model(self.app_name, "IOC") + + num_iocs = 3500 + asns = [10000 + i % 10 for i in range(num_iocs)] + + for asn in asns: + ioc_old.objects.create(asn=asn) + + new_state = self.apply_tested_migration() + ioc_new = new_state.apps.get_model(self.app_name, "IOC") + as_new = new_state.apps.get_model(self.app_name, "AutonomousSystem") + + for ioc in ioc_new.objects.all(): + self.assertIsNotNone(ioc.autonomous_system) + self.assertIn(ioc.autonomous_system.asn, range(10000, 10010)) + + self.assertEqual(as_new.objects.count(), 10) + + @tag("migration") class TestCredentialModelMigration(MigrationTestCase): """Tests that credentials are correctly migrated from ArrayField to Credential model.""" diff --git a/tests/test_models.py b/tests/test_models.py index 4e1d429d..011f8c58 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -20,7 +20,7 @@ def test_ioc_model(self): self.assertEqual(self.ioc.payload_request, True) self.assertEqual(self.ioc.related_urls, []) self.assertEqual(self.ioc.ip_reputation, "") - self.assertEqual(self.ioc.asn, "12345") + self.assertEqual(self.ioc.autonomous_system.asn, "12345") self.assertEqual(self.ioc.destination_ports, [22, 23, 24]) self.assertEqual(self.ioc.login_attempts, 1) self.assertEqual(self.ioc.recurrence_probability, 0.1) From 489f07026aae6c6917d52f7d1bda55b8471d13a1 Mon Sep 17 00:00:00 2001 From: Manik Date: Fri, 13 Mar 2026 21:02:15 +0530 Subject: [PATCH 058/109] perf: bulk-prefetch extraction data. Closes #1008 (#1020) * perf: bulk-prefetch extraction data. Closes #1008 * fix: restore exit(9) and undo accidental formatting changes --- .../cronjobs/extraction/ioc_processor.py | 5 +- greedybear/cronjobs/extraction/utils.py | 79 +++++++++++-------- tests/test_extraction_utils.py | 22 +++--- tests/test_ioc_processor.py | 13 ++- 4 files changed, 62 insertions(+), 57 deletions(-) diff --git a/greedybear/cronjobs/extraction/ioc_processor.py b/greedybear/cronjobs/extraction/ioc_processor.py index 13cf4c79..3bdd03ed 100644 --- a/greedybear/cronjobs/extraction/ioc_processor.py +++ b/greedybear/cronjobs/extraction/ioc_processor.py @@ -3,7 +3,7 @@ from greedybear.consts import PAYLOAD_REQUEST, SCANNER from greedybear.cronjobs.extraction.utils import is_whatsmyip_domain from greedybear.cronjobs.repositories import IocRepository, SensorRepository -from greedybear.models import IOC, IocType +from greedybear.models import IOC, IocType, WhatsMyIPDomain class IocProcessor: @@ -25,6 +25,7 @@ def __init__(self, ioc_repo: IocRepository, sensor_repo: SensorRepository): self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") self.ioc_repo = ioc_repo self.sensor_repo = sensor_repo + self._whatsmyip_domains = set(WhatsMyIPDomain.objects.values_list("domain", flat=True)) def add_ioc( self, @@ -52,7 +53,7 @@ def add_ioc( self.log.debug(f"not saved {ioc} because it is a sensor") return None - if ioc.type == IocType.DOMAIN and is_whatsmyip_domain(ioc.name): + if ioc.type == IocType.DOMAIN and is_whatsmyip_domain(ioc.name, self._whatsmyip_domains): self.log.debug(f"not saved {ioc} because it is a whats-my-ip domain") return None diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index a0af4fcc..e2ba2370 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -9,7 +9,7 @@ from greedybear.consts import DOMAIN, IP from greedybear.cronjobs.repositories import ASRepository -from greedybear.models import IOC, FireHolList, MassScanner, WhatsMyIPDomain +from greedybear.models import IOC, FireHolList, MassScanner def parse_timestamp(timestamp: str) -> datetime: @@ -26,24 +26,21 @@ def parse_timestamp(timestamp: str) -> datetime: return datetime.fromisoformat(timestamp).replace(tzinfo=None) -def is_whatsmyip_domain(domain: str) -> bool: +def is_whatsmyip_domain(domain: str, whatsmyip_domains: set) -> bool: """ Check if a domain is a known "what's my IP" service. Args: domain: Domain name to check. + whatsmyip_domains: Set of known whats-my-ip domains. Returns: True if the domain is in the WhatsMyIP list, False otherwise. """ - try: - WhatsMyIPDomain.objects.get(domain=domain) - except WhatsMyIPDomain.DoesNotExist: - return False - return True + return domain in whatsmyip_domains -def correct_ip_reputation(ip: str, ip_reputation: str) -> str: +def correct_ip_reputation(ip: str, ip_reputation: str, mass_scanner_ips: set) -> str: """ Correct IP reputation based on mass scanner database. Overrides reputation to "mass scanner" if the IP is found in the MassScanners table. @@ -52,21 +49,18 @@ def correct_ip_reputation(ip: str, ip_reputation: str) -> str: Args: ip: IP address to check. ip_reputation: Current reputation string. + mass_scanner_ips: A set of known mass scanner IPs. Returns: Corrected reputation string. """ if not ip_reputation or ip_reputation == "known attacker": - try: - MassScanner.objects.get(ip_address=ip) - except MassScanner.DoesNotExist: - pass - else: + if ip in mass_scanner_ips: ip_reputation = "mass scanner" return ip_reputation -def get_firehol_categories(ip: str, extracted_ip) -> list[str]: +def get_firehol_categories(ip: str, extracted_ip, firehol_exact_map: dict, cidr_entries: list) -> list[str]: """ Get FireHol categories for an IP address. Checks both exact IP matches (for .ipset files) and network range @@ -75,29 +69,17 @@ def get_firehol_categories(ip: str, extracted_ip) -> list[str]: Args: ip: IP address string. extracted_ip: Parsed IP address object from ipaddress library. + firehol_exact_map: Dict mapping IPs to lists of FireHol sources. + cidr_entries: List of tuples (ip_network, source) for CIDR entries. Returns: List of FireHol source categories. """ - firehol_categories = [] - - # First check for exact IP match (for .ipset files) - exact_matches = FireHolList.objects.filter(ip_address=ip).values_list("source", flat=True) - # Filter out empty strings (from default='') - firehol_categories.extend([source for source in exact_matches if source]) + firehol_categories = list(firehol_exact_map.get(ip, [])) - # Then check if IP is within any network ranges (for .netset files) - # Only query entries that contain '/' (CIDR notation) - network_entries = FireHolList.objects.filter(ip_address__contains="/") - for entry in network_entries: - try: - network_range = ip_network(entry.ip_address, strict=False) - # Check entry.source is not empty and not already in list - if extracted_ip in network_range and entry.source and entry.source not in firehol_categories: - firehol_categories.append(entry.source) - except (ValueError, IndexError): - # Not a valid network range, skip - continue + for network, source in cidr_entries: + if source and extracted_ip in network and source not in firehol_categories: + firehol_categories.append(source) return firehol_categories @@ -110,6 +92,9 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: Enriches IOCs with FireHol categories at creation time to ensure only fresh data is used. + Performs bulk prefetching before the main loop to eliminate N+1 queries + by injecting the bulk data into the helper evaluation functions. + Args: hits: List of Elasticsearch hit dictionaries. @@ -119,6 +104,32 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: hits_by_ip = defaultdict(list) for hit in hits: hits_by_ip[hit["src_ip"]].append(hit) + + all_ips = list(hits_by_ip.keys()) + + # --- Bulk prefetch: FireHol exact matches --- + firehol_exact_map = defaultdict(list) + for entry_ip, source in FireHolList.objects.filter( + ip_address__in=all_ips, + ).values_list("ip_address", "source"): + if source: + firehol_exact_map[entry_ip].append(source) + + # --- Bulk prefetch: FireHol CIDR entries --- + cidr_entries = [] + for entry in FireHolList.objects.filter(ip_address__contains="/"): + try: + cidr_entries.append((ip_network(entry.ip_address, strict=False), entry.source)) + except (ValueError, IndexError): + continue + + # --- Bulk prefetch: MassScanner IPs --- + mass_scanner_ips = set( + MassScanner.objects.filter( + ip_address__in=all_ips, + ).values_list("ip_address", flat=True) + ) + iocs = [] as_repository = ASRepository() # single instance for this batch for ip, hits in hits_by_ip.items(): @@ -126,7 +137,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: 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: continue - firehol_categories = get_firehol_categories(ip, extracted_ip) + firehol_categories = get_firehol_categories(ip, extracted_ip, firehol_exact_map, cidr_entries) # Single pass over hits to accumulate all derived data dest_ports = [] @@ -158,7 +169,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: name=ip, type=get_ioc_type(ip), interaction_count=len(hits), - ip_reputation=correct_ip_reputation(ip, hits[0].get("ip_rep", "")), + ip_reputation=correct_ip_reputation(ip, hits[0].get("ip_rep", ""), mass_scanner_ips), autonomous_system=autonomous_system, destination_ports=sorted(set(dest_ports)), login_attempts=login_attempts, diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index eb70e7c2..36a23cf2 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -11,7 +11,7 @@ is_whatsmyip_domain, threatfox_submission, ) -from greedybear.models import FireHolList, MassScanner, WhatsMyIPDomain +from greedybear.models import FireHolList, MassScanner from . import CustomTestCase, ExtractionTestCase @@ -286,33 +286,29 @@ def test_invalid_cidr_negative_numbers(self): class TestIsWhatsmyipDomain(CustomTestCase): def test_returns_true_for_known_domain(self): - WhatsMyIPDomain.objects.create(domain="some.domain.com") - result = is_whatsmyip_domain("some.domain.com") + result = is_whatsmyip_domain("some.domain.com", {"some.domain.com"}) self.assertTrue(result) def test_returns_false_for_unknown_domain(self): - result = is_whatsmyip_domain("another.domain.com") + result = is_whatsmyip_domain("another.domain.com", {"some.domain.com"}) self.assertFalse(result) class TestCorrectIpReputationTestCase(CustomTestCase): - def test_returns_mass_scanner_when_in_database(self): - MassScanner.objects.create(ip_address="1.2.3.4") - result = correct_ip_reputation("1.2.3.4", "known attacker") + def test_returns_mass_scanner_when_in_set(self): + result = correct_ip_reputation("1.2.3.4", "known attacker", {"1.2.3.4"}) self.assertEqual(result, "mass scanner") - def test_returns_original_when_not_in_database(self): - result = correct_ip_reputation("1.2.3.4", "known attacker") + def test_returns_original_when_not_in_set(self): + result = correct_ip_reputation("1.2.3.4", "known attacker", {"5.6.7.8"}) self.assertEqual(result, "known attacker") def test_checks_mass_scanner_for_empty_reputation(self): - MassScanner.objects.create(ip_address="1.2.3.4") - result = correct_ip_reputation("1.2.3.4", "") + result = correct_ip_reputation("1.2.3.4", "", {"1.2.3.4"}) self.assertEqual(result, "mass scanner") def test_preserves_other_reputations(self): - MassScanner.objects.create(ip_address="1.2.3.4") - result = correct_ip_reputation("1.2.3.4", "bot") + result = correct_ip_reputation("1.2.3.4", "bot", {"1.2.3.4"}) self.assertEqual(result, "bot") diff --git a/tests/test_ioc_processor.py b/tests/test_ioc_processor.py index 20e253b3..1a7d1812 100644 --- a/tests/test_ioc_processor.py +++ b/tests/test_ioc_processor.py @@ -1,5 +1,5 @@ from datetime import date, datetime -from unittest.mock import Mock, patch +from unittest.mock import Mock from greedybear.consts import PAYLOAD_REQUEST, SCANNER from greedybear.cronjobs.extraction.ioc_processor import IocProcessor @@ -22,15 +22,13 @@ def test_filters_sensor_ips(self): self.assertIsNone(result) self.mock_ioc_repo.save.assert_not_called() - @patch("greedybear.cronjobs.extraction.ioc_processor.is_whatsmyip_domain") - def test_filters_whatsmyip_domains(self, mock_whatsmyip): - mock_whatsmyip.return_value = True + def test_filters_whatsmyip_domains(self): + self.processor._whatsmyip_domains = {"some.domain.com"} ioc = self._create_mock_ioc(name="some.domain.com", ioc_type=IocType.DOMAIN) result = self.processor.add_ioc(ioc, attack_type=SCANNER) self.assertIsNone(result) - mock_whatsmyip.assert_called_once_with("some.domain.com") self.mock_ioc_repo.save.assert_not_called() def test_creates_new_ioc_when_not_exists(self): @@ -178,8 +176,8 @@ def test_full_update_flow(self): self.assertEqual(len(result.days_seen), 2) self.assertTrue(result.payload_request) - @patch("greedybear.cronjobs.extraction.ioc_processor.is_whatsmyip_domain") - def test_only_checks_whatsmyip_for_domains(self, mock_whatsmyip): + def test_only_checks_whatsmyip_for_domains(self): + self.processor._whatsmyip_domains = {"1.2.3.4"} self.mock_sensor_repo.cache = {} self.mock_ioc_repo.get_ioc_by_name.return_value = None ioc = self._create_mock_ioc(name="1.2.3.4", ioc_type=IocType.IP) @@ -187,7 +185,6 @@ def test_only_checks_whatsmyip_for_domains(self, mock_whatsmyip): result = self.processor.add_ioc(ioc, attack_type=SCANNER) - mock_whatsmyip.assert_not_called() self.assertIsNotNone(result) From 143f6ff9efe2487f626efbd66faae434f8831326 Mon Sep 17 00:00:00 2001 From: Abhijeet Sapar <139008505+Abhijeet17o@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:47:37 +0530 Subject: [PATCH 059/109] fix: eliminate N+1 queries in IocRepository.add_honeypot_to_ioc(). Closes #1012 (#1024) * fix: eliminate N+1 queries in IocRepository. Closes #1012 Two targeted fixes: 1. Add prefetch_related('general_honeypot') to get_ioc_by_name() so subsequent .all() calls in add_honeypot_to_ioc() read from Django's prefetch cache instead of hitting the DB per IOC. 2. Change _honeypot_cache to store GeneralHoneypot objects instead of booleans, so add_honeypot_to_ioc() can call .add() using the cached object directly without an extra get_hp_by_name() DB hit. * Addressing cache-miss behavior in test_ioc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * revert: drop extra normalization logic in add_honeypot_to_ioc --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- greedybear/cronjobs/repositories/ioc.py | 16 ++++-- tests/test_ioc_repository.py | 76 ++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/greedybear/cronjobs/repositories/ioc.py b/greedybear/cronjobs/repositories/ioc.py index 52171aed..f3254bf3 100644 --- a/greedybear/cronjobs/repositories/ioc.py +++ b/greedybear/cronjobs/repositories/ioc.py @@ -18,7 +18,7 @@ class IocRepository: def __init__(self): """Initialize the repository and populate the honeypot cache from the database.""" self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") - self._honeypot_cache = {self._normalize_name(hp.name): hp.active for hp in GeneralHoneypot.objects.all()} + self._honeypot_cache = {self._normalize_name(hp.name): hp for hp in GeneralHoneypot.objects.all()} def _normalize_name(self, name: str) -> str: """Normalize honeypot names for consistent cache and DB usage.""" @@ -38,8 +38,11 @@ def add_honeypot_to_ioc(self, honeypot_name: str, ioc: IOC) -> IOC: honeypot_set = {hp.name for hp in ioc.general_honeypot.all()} if honeypot_name not in honeypot_set: self.log.debug(f"adding honeypot {honeypot_name} to IoC {ioc}") - honeypot = self.get_hp_by_name(honeypot_name) - ioc.general_honeypot.add(honeypot) + honeypot = self._honeypot_cache.get(self._normalize_name(honeypot_name)) + if honeypot is not None: + ioc.general_honeypot.add(honeypot) + else: + self.log.error(f"Honeypot '{honeypot_name}' not found in cache; skipping association for IOC {ioc}") return ioc def create_honeypot(self, honeypot_name: str) -> GeneralHoneypot: @@ -69,7 +72,7 @@ def create_honeypot(self, honeypot_name: str) -> GeneralHoneypot: if honeypot is None: raise e - self._honeypot_cache[normalized] = honeypot.active + self._honeypot_cache[normalized] = honeypot return honeypot def get_active_honeypots(self) -> list[GeneralHoneypot]: @@ -92,7 +95,7 @@ def get_ioc_by_name(self, name: str) -> IOC | None: The matching IOC, or None if not found. """ try: - return IOC.objects.get(name=name) + return IOC.objects.prefetch_related("general_honeypot").get(name=name) except IOC.DoesNotExist: return None @@ -129,7 +132,8 @@ def is_enabled(self, honeypot_name: str) -> bool: True if the honeypot is enabled, False otherwise. """ normalized = self._normalize_name(honeypot_name) - return self._honeypot_cache.get(normalized, False) + hp = self._honeypot_cache.get(normalized) + return hp.active if hp is not None else False def is_ready_for_extraction(self, honeypot_name: str) -> bool: """ diff --git a/tests/test_ioc_repository.py b/tests/test_ioc_repository.py index 3e69ee9d..31a10672 100644 --- a/tests/test_ioc_repository.py +++ b/tests/test_ioc_repository.py @@ -100,25 +100,34 @@ def test_is_enabled_returns_false_for_inactive_honeypot(self): def test_add_honeypot_to_ioc_adds_new_honeypot(self): ioc = IOC.objects.create(name="1.2.3.4", type="ip") - honeypot = GeneralHoneypot.objects.create(name="TestPot", active=True) - result = self.repo.add_honeypot_to_ioc("TestPot", ioc) + honeypot = GeneralHoneypot.objects.get(name="Cowrie") + result = self.repo.add_honeypot_to_ioc("Cowrie", ioc) self.assertIn(honeypot, result.general_honeypot.all()) + def test_add_honeypot_to_ioc_cache_miss_logs_error(self): + """Honeypot created after repo init is not in cache; association is skipped and error is logged.""" + ioc = IOC.objects.create(name="1.2.3.4", type="ip") + GeneralHoneypot.objects.create(name="NewPot", active=True) + with self.assertLogs("greedybear.cronjobs.repositories.ioc", level="ERROR") as cm: + result = self.repo.add_honeypot_to_ioc("NewPot", ioc) + self.assertEqual(result.general_honeypot.count(), 0) + self.assertTrue(any("NewPot" in msg for msg in cm.output)) + def test_add_honeypot_to_ioc_idempotent(self): ioc = IOC.objects.create(name="1.2.3.4", type="ip") - honeypot = GeneralHoneypot.objects.create(name="TestPot", active=True) + honeypot = GeneralHoneypot.objects.get(name="Cowrie") ioc.general_honeypot.add(honeypot) initial_count = ioc.general_honeypot.count() - result = self.repo.add_honeypot_to_ioc("TestPot", ioc) + result = self.repo.add_honeypot_to_ioc("Cowrie", ioc) self.assertEqual(result.general_honeypot.count(), initial_count) self.assertEqual(ioc.general_honeypot.count(), 1) def test_add_honeypot_to_ioc_multiple_honeypots(self): ioc = IOC.objects.create(name="1.2.3.4", type="ip") - hp1 = GeneralHoneypot.objects.create(name="Pot1", active=True) - hp2 = GeneralHoneypot.objects.create(name="Pot2", active=True) - self.repo.add_honeypot_to_ioc("Pot1", ioc) - self.repo.add_honeypot_to_ioc("Pot2", ioc) + hp1 = GeneralHoneypot.objects.get(name="Cowrie") + hp2 = GeneralHoneypot.objects.get(name="Log4pot") + self.repo.add_honeypot_to_ioc("Cowrie", ioc) + self.repo.add_honeypot_to_ioc("Log4pot", ioc) self.assertEqual(ioc.general_honeypot.count(), 2) self.assertIn(hp1, ioc.general_honeypot.all()) self.assertIn(hp2, ioc.general_honeypot.all()) @@ -421,6 +430,57 @@ def test_bulk_update_scores_with_custom_batch_size(self): self.assertEqual(updated1.recurrence_probability, 0.75) self.assertEqual(updated2.recurrence_probability, 0.85) + # --- Tests for N+1 fix --- + + def test_honeypot_cache_stores_generalhoneypot_objects(self): + """_honeypot_cache must store GeneralHoneypot instances, not booleans.""" + self.assertGreater( + len(self.repo._honeypot_cache), + 0, + "Cache must be non-empty for this test to be meaningful", + ) + for key, value in self.repo._honeypot_cache.items(): + self.assertIsInstance( + value, + GeneralHoneypot, + f"Cache value for '{key}' should be a GeneralHoneypot instance, got {type(value)}", + ) + + def test_get_ioc_by_name_prefetches_general_honeypot(self): + """Accessing general_honeypot on an IOC fetched via get_ioc_by_name must not trigger extra DB queries.""" + ioc = self.repo.get_ioc_by_name("140.246.171.141") + self.assertIsNotNone(ioc) + with self.assertNumQueries(0): + list(ioc.general_honeypot.all()) + + def test_add_honeypot_to_ioc_uses_cache_not_db(self): + """When honeypot is in cache and the IOC was fetched with prefetch, no extra queries are needed.""" + cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0] + + # Case 1: IOC already associated with Cowrie - membership check uses prefetch (0 queries), + # and skips the add entirely (already associated), producing zero DB queries + ioc = IOC.objects.create(name="5.5.5.5", type="ip") + ioc.general_honeypot.add(cowrie_hp) + ioc_fetched = self.repo.get_ioc_by_name("5.5.5.5") + with self.assertNumQueries(0): + result = self.repo.add_honeypot_to_ioc("Cowrie", ioc_fetched) + self.assertIn(cowrie_hp, result.general_honeypot.all()) + + # Case 2: IOC not yet associated - membership check uses prefetch (0 queries), + # honeypot lookup uses in-memory cache (0 queries), only the M2M INSERT fires + IOC.objects.create(name="6.6.6.6", type="ip") + ioc2_fetched = self.repo.get_ioc_by_name("6.6.6.6") + with self.assertNumQueries(1): # only M2M INSERT + result2 = self.repo.add_honeypot_to_ioc("Cowrie", ioc2_fetched) + self.assertIn(cowrie_hp, result2.general_honeypot.all()) + + def test_create_honeypot_stores_object_in_cache(self): + """create_honeypot must store the GeneralHoneypot object in cache, not a boolean.""" + hp = self.repo.create_honeypot("CacheTestPot") + cached = self.repo._honeypot_cache.get("cachetestpot") + self.assertIsInstance(cached, GeneralHoneypot) + self.assertEqual(cached.pk, hp.pk) + class TestScoringIntegration(CustomTestCase): """Integration tests for scoring jobs using IocRepository.""" From 51e3679fe149cdda0afd4375e1388254c63bbd92 Mon Sep 17 00:00:00 2001 From: SupRaKoshti Date: Mon, 16 Mar 2026 21:35:40 +0530 Subject: [PATCH 060/109] Migrate from uWSGI to gunicorn. Closes #891 (#904) * Migrate from uWSGI to gunicorn. Closes #891 * refactor: migrate from uWSGI server to Gunicorn with uWSGI protocol Docker & Compose: - renamed service from `uwsgi` to `app` in default.yml and updated all references in nginx depends_on and qcluster depends_on Gunicorn configuration: - added dynamic worker count using $(( 2 * $(nproc) + 1 )) - added graceful shutdown with --graceful-timeout 30 and --timeout 120 - added stop_grace_period: 30s to app service to match graceful timeout - enabled uWSGI binary protocol via --protocol uwsgi flag - added custom gunicorn log directory (/var/log/greedybear/gunicorn) Nginx: - updated http.conf and https.conf to use uwsgi_pass instead of proxy_pass and uwsgi_cache instead of proxy_cache to match gunicorn uWSGI binary protocol - updated upstream server name from uwsgi:8001 to app:8001 - django_server.conf kept as proxy_pass (used only in local dev with Django runserver which speaks plain HTTP) Health checks: - replaced curl HTTP healthcheck with Python TCP socket check in both default.yml and Dockerfile since port 8001 now speaks uWSGI binary protocol, not HTTP - updated Dockerfile comment accordingly gbctl script: - updated all container name references from greedybear_gunicorn to greedybear_app in cmd_logs, cmd_health, cmd_create_admin and check_downgrade functions * fix: remove redundant Dockerfile healthcheck, add executable bit and optimize healthcheck - removed redundant HEALTHCHECK from Dockerfile since default.yml healthcheck already overrides it when running via Docker Compose - added missing executable bit (+x) to entrypoint_gunicorn.sh - optimized healthcheck by adding a separate HTTP bind on port 8002 so healthcheck requests bypass the uWSGI queue on port 8001 and use curl -f http://localhost:8002 instead of Python TCP socket * fix: migrate gunicorn to uWSGI over UNIX sockets - bind gunicorn to two UNIX sockets instead of TCP port 8001: - unix:/run/gunicorn-main.sock for all application traffic - unix:/run/gunicorn-health.sock for health checks only (separate health socket avoids queuing behind busy workers, see https://github.com/benoitc/gunicorn/issues/1417) - add shared `gunicorn_sockets` volume mounted at /run in both app and nginx containers so both can access the socket files - replace app healthcheck with `test -S /run/gunicorn-health.sock` since there is no longer a TCP port to probe - update nginx upstreams in http.conf and https.conf: - django_main -> unix:/run/gunicorn-main.sock - django_health -> unix:/run/gunicorn-health.sock - move /hc location from locations.conf into http/https.conf and route it to django_health upstream fixes ForbiddenUWSGIRequest errors that occurred when gunicorn rejected TCP connections from nginx's Docker network IP (172.20.0.x), since UNIX socket connections bypass the uWSGI IP allowlist entirely * fix: add --preload to gunicorn to fix admin session issue without --preload, each gunicorn worker independently loads the app and calls get_random_secret_key(), resulting in every worker having a different SECRET_KEY. when a login request is handled by worker A and the next request is handled by worker B, the SECRET_KEY mismatch causes Django to flush the session and redirect back to login. with --preload, the app is loaded once in the master process before forking workers, so all workers inherit the same SECRET_KEY via fork(). * correct comment * add config file * mount config file * ensure log directories exist * remove separate healthcheck socket * fix comment --------- Co-authored-by: tim <46972822+regulartim@users.noreply.github.com> --- configuration/gunicorn/config.py | 22 ++++++++++++++ configuration/nginx/django_server.conf | 4 +-- configuration/nginx/http.conf | 10 +++---- configuration/nginx/https.conf | 10 +++---- configuration/uwsgi/greedybear.ini | 26 ---------------- docker/Dockerfile | 7 ++--- docker/default.yml | 30 ++++++++++--------- docker/elasticsearch.yml | 2 +- ...ypoint_uwsgi.sh => entrypoint_gunicorn.sh} | 3 ++ docker/local.override.yml | 2 +- docker/stag.override.yml | 2 +- docker/version.override.yml | 2 +- gbctl | 14 ++++----- requirements/project-requirements.txt | 3 +- 14 files changed, 68 insertions(+), 69 deletions(-) create mode 100644 configuration/gunicorn/config.py delete mode 100644 configuration/uwsgi/greedybear.ini rename docker/{entrypoint_uwsgi.sh => entrypoint_gunicorn.sh} (88%) diff --git a/configuration/gunicorn/config.py b/configuration/gunicorn/config.py new file mode 100644 index 00000000..6d001381 --- /dev/null +++ b/configuration/gunicorn/config.py @@ -0,0 +1,22 @@ +import multiprocessing + +# Server socket +bind = "unix:/run/gunicorn/main.sock" + +# Worker processes +workers = 2 * multiprocessing.cpu_count() + 1 +max_requests = 1000 +max_requests_jitter = 50 + +# Protocol +protocol = "uwsgi" + +# Server mechanics +preload_app = True +graceful_timeout = 30 +timeout = 120 +pidfile = "/run/gunicorn/gunicorn.pid" + +# Logging +accesslog = "/var/log/greedybear/gunicorn/access.log" +errorlog = "/var/log/greedybear/gunicorn/error.log" diff --git a/configuration/nginx/django_server.conf b/configuration/nginx/django_server.conf index 35ee2a7b..4fc17319 100644 --- a/configuration/nginx/django_server.conf +++ b/configuration/nginx/django_server.conf @@ -13,14 +13,14 @@ server { alias /var/www/static/; } - # All requests to the Django/UWSGI server. + # All requests to the Django/Gunicorn server. location / { proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Url-Scheme $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_redirect off; - proxy_pass http://uwsgi:8001; + proxy_pass http://app:8001; client_max_body_size 100m; } diff --git a/configuration/nginx/http.conf b/configuration/nginx/http.conf index ce1481fc..153e5bf7 100644 --- a/configuration/nginx/http.conf +++ b/configuration/nginx/http.conf @@ -1,6 +1,6 @@ # the upstream component nginx needs to connect to -upstream django { - server uwsgi:8001 fail_timeout=30s; +upstream django_main { + server unix:/run/gunicorn/main.sock fail_timeout=30s; } uwsgi_cache_path /var/cache/nginx/feeds keys_zone=feeds_cache:10m max_size=10g @@ -20,7 +20,7 @@ server { } location ^~/admin { - uwsgi_pass django; + uwsgi_pass django_main; uwsgi_pass_header Authorization; uwsgi_pass_request_headers on; uwsgi_read_timeout 45; @@ -29,7 +29,7 @@ server { } location ~^/api/feeds { - uwsgi_pass django; + uwsgi_pass django_main; uwsgi_pass_header Authorization; uwsgi_pass_request_headers on; uwsgi_read_timeout 600; @@ -46,7 +46,7 @@ server { } location / { - uwsgi_pass django; + uwsgi_pass django_main; uwsgi_pass_header Authorization; uwsgi_pass_request_headers on; uwsgi_read_timeout 45; diff --git a/configuration/nginx/https.conf b/configuration/nginx/https.conf index 1b78cf53..5c3c5b49 100644 --- a/configuration/nginx/https.conf +++ b/configuration/nginx/https.conf @@ -1,6 +1,6 @@ # the upstream component nginx needs to connect to -upstream django { - server uwsgi:8001 fail_timeout=30s; +upstream django_main { + server unix:/run/gunicorn/main.sock fail_timeout=30s; } uwsgi_cache_path /var/cache/nginx/feeds keys_zone=feeds_cache:10m max_size=10g @@ -36,7 +36,7 @@ server { } location ^~/admin { - uwsgi_pass django; + uwsgi_pass django_main; uwsgi_pass_header Authorization; uwsgi_pass_request_headers on; uwsgi_read_timeout 45; @@ -45,7 +45,7 @@ server { } location ~^/api/feeds { - uwsgi_pass django; + uwsgi_pass django_main; uwsgi_pass_header Authorization; uwsgi_pass_request_headers on; uwsgi_read_timeout 600; @@ -62,7 +62,7 @@ server { } location / { - uwsgi_pass django; + uwsgi_pass django_main; uwsgi_pass_header Authorization; uwsgi_pass_request_headers on; uwsgi_read_timeout 45; diff --git a/configuration/uwsgi/greedybear.ini b/configuration/uwsgi/greedybear.ini deleted file mode 100644 index 1b02118e..00000000 --- a/configuration/uwsgi/greedybear.ini +++ /dev/null @@ -1,26 +0,0 @@ -[uwsgi] -project = greedybear -base = /opt/deploy/greedybear - -chdir = %(base) -module = %(project).wsgi:application - -master = true -processes = 16 - -socket = 0.0.0.0:8001 -chown = www-data:www-data -vacuum = true -single-interpreter = true -die-on-term = true - -logto = /var/log/greedybear/uwsgi/greedybear.log -uid = www-data -gid = www-data - -max-requests = 1000 -max-worker-lifetime = 3600 -reload-on-rss = 2048 -worker-reload-mercy = 3600 - -buffer-size = 32768 diff --git a/docker/Dockerfile b/docker/Dockerfile index 18a33378..f764a361 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,10 +27,9 @@ WORKDIR $PYTHONPATH # Install runtime dependencies # - libgomp1 is required for model training -# - libexpat1 is required by uWSGI -# - curl for healthcheck +# - curl is used for healthcheck RUN apt-get update && apt-get install -y --no-install-recommends \ - libgomp1 libexpat1 curl \ + libgomp1 curl \ && rm -rf /var/lib/apt/lists/* # Install python packages @@ -42,7 +41,7 @@ COPY . $PYTHONPATH COPY --from=frontend-build /app/build /var/www/reactapp # separation is required to avoid to re-execute os installation in case of change of python requirements -RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/uwsgi \ +RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \ && touch ${LOG_PATH}/django/api.log ${LOG_PATH}/django/api_errors.log \ && touch ${LOG_PATH}/django/greedybear.log ${LOG_PATH}/django/greedybear_errors.log \ && touch ${LOG_PATH}/django/django_q.log ${LOG_PATH}/django/django_q_errors.log \ diff --git a/docker/default.yml b/docker/default.yml index 42f28a8b..c7c2600d 100644 --- a/docker/default.yml +++ b/docker/default.yml @@ -15,27 +15,26 @@ services: start_period: 10s start_interval: 1s - uwsgi: + app: image: intelowlproject/greedybear:prod - container_name: greedybear_uwsgi + container_name: greedybear_app restart: unless-stopped + stop_grace_period: 30s volumes: - - ../configuration/uwsgi/greedybear.ini:/etc/uwsgi/sites/greedybear.ini + - ../configuration/gunicorn/config.py:/etc/gunicorn/config.py - generic_logs:/var/log/greedybear - static_content:/opt/deploy/greedybear/static + - gunicorn_sockets:/run/gunicorn entrypoint: - - ./docker/entrypoint_uwsgi.sh - command: ["uwsgi", "--ini", "/etc/uwsgi/sites/greedybear.ini", "--stats", "127.0.0.1:1717", "--stats-http"] - expose: - - "8001" - - "1717" + - ./docker/entrypoint_gunicorn.sh + command: ["gunicorn", "greedybear.wsgi:application", "-c", "/etc/gunicorn/config.py"] env_file: - env_file depends_on: postgres: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:1717 || exit 1"] + test: ["CMD-SHELL", "test -S /run/gunicorn/main.sock && kill -0 $(cat /run/gunicorn/gunicorn.pid)"] interval: 10s timeout: 2s retries: 30 @@ -52,19 +51,21 @@ services: - ../configuration/nginx/locations.conf:/etc/nginx/locations.conf - nginx_logs:/var/log/nginx - static_content:/var/www/static + - gunicorn_sockets:/run/gunicorn ports: - "80:80" - depends_on: - uwsgi: + depends_on: + app: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost/hc || exit 1"] + test: ["CMD-SHELL", "curl -f http://localhost/api/ || exit 1"] interval: 10s timeout: 2s retries: 3 start_period: 10s start_interval: 1s + qcluster: image: intelowlproject/greedybear:prod container_name: greedybear_qcluster @@ -79,7 +80,7 @@ services: depends_on: postgres: condition: service_healthy - uwsgi: + app: condition: service_healthy user: "2000:82" healthcheck: @@ -90,4 +91,5 @@ volumes: nginx_logs: generic_logs: static_content: - mlmodels: \ No newline at end of file + mlmodels: + gunicorn_sockets: diff --git a/docker/elasticsearch.yml b/docker/elasticsearch.yml index 11060476..e89557b0 100644 --- a/docker/elasticsearch.yml +++ b/docker/elasticsearch.yml @@ -1,5 +1,5 @@ services: - uwsgi: + app: depends_on: - elasticsearch diff --git a/docker/entrypoint_uwsgi.sh b/docker/entrypoint_gunicorn.sh similarity index 88% rename from docker/entrypoint_uwsgi.sh rename to docker/entrypoint_gunicorn.sh index 8daec987..fb03fb07 100755 --- a/docker/entrypoint_uwsgi.sh +++ b/docker/entrypoint_gunicorn.sh @@ -16,6 +16,9 @@ python manage.py migrate # Collect static files, overwriting existing ones python manage.py collectstatic --noinput --clear --verbosity 0 +# Ensure log directories exist (volumes may persist from older builds) +mkdir -p /var/log/greedybear/gunicorn + # Fix log file ownership (manage.py commands above run as root and may create new log files) chown -R 2000:82 /var/log/greedybear diff --git a/docker/local.override.yml b/docker/local.override.yml index 37a93c19..c7a76921 100644 --- a/docker/local.override.yml +++ b/docker/local.override.yml @@ -1,5 +1,5 @@ services: - uwsgi: + app: build: context: .. dockerfile: docker/Dockerfile diff --git a/docker/stag.override.yml b/docker/stag.override.yml index 906bb65d..74d8482b 100644 --- a/docker/stag.override.yml +++ b/docker/stag.override.yml @@ -1,5 +1,5 @@ services: - uwsgi: + app: image: intelowlproject/greedybear:stag nginx: diff --git a/docker/version.override.yml b/docker/version.override.yml index 0419468d..d2b02e31 100644 --- a/docker/version.override.yml +++ b/docker/version.override.yml @@ -1,6 +1,6 @@ # you have to populate the ENV variable REACT_APP_INTELOWL_VERSION in the .env file to have this work services: - uwsgi: + app: image: intelowlproject/greedybear:${REACT_APP_INTELOWL_VERSION} nginx: diff --git a/gbctl b/gbctl index e7be5681..c33d39e8 100755 --- a/gbctl +++ b/gbctl @@ -560,8 +560,8 @@ check_downgrade() { detect_docker_cmd local docker_cmd="$DOCKER_CMD" - # Check if uwsgi container exists - local container_name="${PROJECT_NAME}_uwsgi" + # Check if gunicorn container exists + local container_name="${PROJECT_NAME}_app" if ! ${docker_cmd} ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then return fi @@ -669,7 +669,7 @@ cmd_logs() { log_info "Tailing Django application logs (Ctrl+C to exit)..." detect_docker_cmd - local container_name="${PROJECT_NAME}_uwsgi" + local container_name="${PROJECT_NAME}_app" # Check if container is running if ! ${DOCKER_CMD} ps --format '{{.Names}}' | grep -q "^${container_name}$"; then @@ -888,7 +888,7 @@ cmd_health() { local docker_cmd="$DOCKER_CMD" # Check each service - local services=("postgres" "uwsgi" "nginx" "qcluster") + local services=("postgres" "app" "nginx" "qcluster") local all_healthy=true for service in "${services[@]}"; do @@ -962,11 +962,11 @@ cmd_create_admin() { detect_docker_cmd local docker_cmd="$DOCKER_CMD" - local container_name="${PROJECT_NAME}_uwsgi" + local container_name="${PROJECT_NAME}_app" - # Check if uwsgi container is running + # Check if app container is running if ! ${docker_cmd} ps --format '{{.Names}}' | grep -q "^${container_name}$"; then - log_error "GreedyBear uWSGI container is not running. Please start services first." + log_error "GreedyBear Gunicorn container is not running. Please start services first." exit 1 fi diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt index 852aac3d..5d3b0baa 100644 --- a/requirements/project-requirements.txt +++ b/requirements/project-requirements.txt @@ -14,8 +14,7 @@ psycopg2-binary==2.9.11 certego-saas==0.7.11 slack-sdk==3.40.1 -uwsgitop==0.12 -pyuwsgi==2.0.30 +gunicorn==25.1.0 joblib==1.5.3 pandas==3.0.1 From 77b338951ffa2ce7af528deaa9cb9441ec891ea7 Mon Sep 17 00:00:00 2001 From: Sahitya Aryan <115429795+Sahityaaryan@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:22:19 +0530 Subject: [PATCH 061/109] refactor: define IpReputation constants and replace hardcoded strings. Closes #1026 (#1030) * refactor: define IpReputation constants and replace hardcoded strings * refactor: move IpReputation to StrEnum in enums.py --- api/views/utils.py | 5 +++-- greedybear/cronjobs/extraction/utils.py | 9 +++++---- greedybear/cronjobs/mass_scanners.py | 5 +++-- greedybear/cronjobs/repositories/tor.py | 3 ++- greedybear/cronjobs/reverse_dns.py | 5 +++-- greedybear/cronjobs/tor_exit_nodes.py | 5 +++-- greedybear/enums.py | 6 ++++++ greedybear/models.py | 6 ++++-- tests/__init__.py | 5 +++-- tests/test_extraction_utils.py | 15 ++++++++------- tests/test_ioc_processor.py | 15 ++++++++------- tests/test_ioc_repository.py | 7 ++++--- tests/test_models.py | 3 ++- tests/test_reverse_dns.py | 9 +++++---- tests/test_serializers.py | 11 ++++++----- tests/test_tor.py | 5 +++-- 16 files changed, 68 insertions(+), 46 deletions(-) diff --git a/api/views/utils.py b/api/views/utils.py index 3eb53c20..349e60e8 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -18,6 +18,7 @@ from api.serializers import FeedsRequestSerializer, parse_feed_types from greedybear.consts import CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL +from greedybear.enums import IpReputation from greedybear.models import IOC, GeneralHoneypot, Statistics from greedybear.utils import is_ip_address, is_valid_domain @@ -108,9 +109,9 @@ def apply_default_filters(self, query_params): if not query_params: query_params = {} if "include_mass_scanners" not in query_params: - self.exclude_reputation.append("mass scanner") + self.exclude_reputation.append(IpReputation.MASS_SCANNER) if "include_tor_exit_nodes" not in query_params: - self.exclude_reputation.append("tor exit node") + self.exclude_reputation.append(IpReputation.TOR_EXIT_NODE) def set_prioritization(self, prioritize: str): match prioritize: diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index e2ba2370..d168c949 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -9,6 +9,7 @@ from greedybear.consts import DOMAIN, IP from greedybear.cronjobs.repositories import ASRepository +from greedybear.enums import IpReputation from greedybear.models import IOC, FireHolList, MassScanner @@ -43,8 +44,8 @@ def is_whatsmyip_domain(domain: str, whatsmyip_domains: set) -> bool: def correct_ip_reputation(ip: str, ip_reputation: str, mass_scanner_ips: set) -> str: """ Correct IP reputation based on mass scanner database. - Overrides reputation to "mass scanner" if the IP is found in the MassScanners table. - This is necessary because we have seen "mass scanners" incorrectly flagged. + Overrides reputation to MASS_SCANNER if the IP is found in the MassScanners table. + This is necessary because we have seen mass scanners incorrectly flagged. Args: ip: IP address to check. @@ -54,9 +55,9 @@ def correct_ip_reputation(ip: str, ip_reputation: str, mass_scanner_ips: set) -> Returns: Corrected reputation string. """ - if not ip_reputation or ip_reputation == "known attacker": + if not ip_reputation or ip_reputation == IpReputation.KNOWN_ATTACKER: if ip in mass_scanner_ips: - ip_reputation = "mass scanner" + ip_reputation = IpReputation.MASS_SCANNER return ip_reputation diff --git a/greedybear/cronjobs/mass_scanners.py b/greedybear/cronjobs/mass_scanners.py index 74da6e6e..7ab1fc94 100644 --- a/greedybear/cronjobs/mass_scanners.py +++ b/greedybear/cronjobs/mass_scanners.py @@ -5,6 +5,7 @@ from greedybear.cronjobs.base import Cronjob from greedybear.cronjobs.extraction.utils import is_valid_ipv4 from greedybear.cronjobs.repositories import IocRepository, MassScannerRepository +from greedybear.enums import IpReputation class MassScannersCron(Cronjob): @@ -90,6 +91,6 @@ def _update_old_ioc(self, ip_address: str): Args: ip_address: IP address to update. """ - updated = self.ioc_repo.update_ioc_reputation(ip_address, "mass scanner") + updated = self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.MASS_SCANNER) if updated: - self.log.debug(f"Updated IOC {ip_address} reputation to 'mass scanner'") + self.log.debug(f"Updated IOC {ip_address} reputation to '{IpReputation.MASS_SCANNER}'") diff --git a/greedybear/cronjobs/repositories/tor.py b/greedybear/cronjobs/repositories/tor.py index 0e2a6bf7..c0b0ba47 100644 --- a/greedybear/cronjobs/repositories/tor.py +++ b/greedybear/cronjobs/repositories/tor.py @@ -1,5 +1,6 @@ import logging +from greedybear.enums import IpReputation from greedybear.models import TorExitNode @@ -9,7 +10,7 @@ class TorRepository: def __init__(self): self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") - def get_or_create(self, ip_address: str, reason: str = "tor exit node") -> tuple[TorExitNode, bool]: + def get_or_create(self, ip_address: str, reason: str = IpReputation.TOR_EXIT_NODE) -> tuple[TorExitNode, bool]: """ Get an existing Tor exit node entry or create a new one. diff --git a/greedybear/cronjobs/reverse_dns.py b/greedybear/cronjobs/reverse_dns.py index aae6e412..974c393b 100644 --- a/greedybear/cronjobs/reverse_dns.py +++ b/greedybear/cronjobs/reverse_dns.py @@ -7,6 +7,7 @@ from greedybear.cronjobs.base import Cronjob from greedybear.cronjobs.repositories import IocRepository from greedybear.cronjobs.repositories.tag import TagRepository +from greedybear.enums import IpReputation from greedybear.models import IOC, IocType # Number of concurrent DNS lookups. @@ -184,6 +185,6 @@ def _update_ioc(self, ip_address: str): Args: ip_address: IP address to update. """ - updated = self.ioc_repo.update_ioc_reputation(ip_address, "mass scanner") + updated = self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.MASS_SCANNER) if updated: - self.log.info(f"Marked {ip_address} as mass scanner via rDNS") + self.log.info(f"Marked {ip_address} as {IpReputation.MASS_SCANNER} via rDNS") diff --git a/greedybear/cronjobs/tor_exit_nodes.py b/greedybear/cronjobs/tor_exit_nodes.py index b4d0609b..8036fb38 100644 --- a/greedybear/cronjobs/tor_exit_nodes.py +++ b/greedybear/cronjobs/tor_exit_nodes.py @@ -6,6 +6,7 @@ from greedybear.cronjobs.extraction.utils import is_valid_ipv4 from greedybear.cronjobs.repositories import IocRepository from greedybear.cronjobs.repositories.tor import TorRepository +from greedybear.enums import IpReputation class TorExitNodesCron(Cronjob): @@ -50,6 +51,6 @@ def run(self) -> None: def _update_old_ioc(self, ip_address: str): """Update the IP reputation of an existing IOC to mark it as a Tor exit node.""" - updated = self.ioc_repo.update_ioc_reputation(ip_address, "tor exit node") + updated = self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.TOR_EXIT_NODE) if updated: - self.log.debug(f"Updated IOC {ip_address} reputation to 'tor exit node'") + self.log.debug(f"Updated IOC {ip_address} reputation to '{IpReputation.TOR_EXIT_NODE}'") diff --git a/greedybear/enums.py b/greedybear/enums.py index fed69651..b09f4815 100644 --- a/greedybear/enums.py +++ b/greedybear/enums.py @@ -4,3 +4,9 @@ class FrontendPage(enum.Enum): REGISTER = "register" LOGIN = "login" + + +class IpReputation(enum.StrEnum): + MASS_SCANNER = "mass scanner" + TOR_EXIT_NODE = "tor exit node" + KNOWN_ATTACKER = "known attacker" diff --git a/greedybear/models.py b/greedybear/models.py index e25d8266..db9e9970 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -4,6 +4,8 @@ from django.db import models from django.db.models.functions import Lower, Now +from greedybear.enums import IpReputation + class ViewType(models.TextChoices): FEEDS_VIEW = "feeds" @@ -189,7 +191,7 @@ def __str__(self): class TorExitNode(models.Model): ip_address = models.GenericIPAddressField(unique=True) added = models.DateTimeField(db_default=Now()) - reason = models.CharField(max_length=64, blank=True, default="tor exit node") + reason = models.CharField(max_length=64, blank=True, default=IpReputation.TOR_EXIT_NODE) class Meta: indexes = [ @@ -197,7 +199,7 @@ class Meta: ] def __str__(self): - return f"{self.ip_address} (tor exit node)" + return f"{self.ip_address} ({IpReputation.TOR_EXIT_NODE})" class WhatsMyIPDomain(models.Model): diff --git a/tests/__init__.py b/tests/__init__.py index 90f5c0cf..053d2fac 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,6 +6,7 @@ from django.test import TestCase, TransactionTestCase from django_test_migrations.migrator import Migrator +from greedybear.enums import IpReputation from greedybear.models import ( IOC, AutonomousSystem, @@ -66,7 +67,7 @@ def setUpTestData(cls): scanner=True, payload_request=True, related_urls=[], - ip_reputation="mass scanner", + ip_reputation=IpReputation.MASS_SCANNER, autonomous_system=cls.as_obj, destination_ports=[22, 23, 24], login_attempts=1, @@ -87,7 +88,7 @@ def setUpTestData(cls): scanner=True, payload_request=True, related_urls=[], - ip_reputation="tor exit node", + ip_reputation=IpReputation.TOR_EXIT_NODE, autonomous_system=cls.as_obj, destination_ports=[22, 23, 24], login_attempts=1, diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index 36a23cf2..8ca146e1 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -11,6 +11,7 @@ is_whatsmyip_domain, threatfox_submission, ) +from greedybear.enums import IpReputation from greedybear.models import FireHolList, MassScanner from . import CustomTestCase, ExtractionTestCase @@ -296,16 +297,16 @@ def test_returns_false_for_unknown_domain(self): class TestCorrectIpReputationTestCase(CustomTestCase): def test_returns_mass_scanner_when_in_set(self): - result = correct_ip_reputation("1.2.3.4", "known attacker", {"1.2.3.4"}) - self.assertEqual(result, "mass scanner") + result = correct_ip_reputation("1.2.3.4", IpReputation.KNOWN_ATTACKER, {"1.2.3.4"}) + self.assertEqual(result, IpReputation.MASS_SCANNER) def test_returns_original_when_not_in_set(self): - result = correct_ip_reputation("1.2.3.4", "known attacker", {"5.6.7.8"}) - self.assertEqual(result, "known attacker") + result = correct_ip_reputation("1.2.3.4", IpReputation.KNOWN_ATTACKER, {"5.6.7.8"}) + self.assertEqual(result, IpReputation.KNOWN_ATTACKER) def test_checks_mass_scanner_for_empty_reputation(self): result = correct_ip_reputation("1.2.3.4", "", {"1.2.3.4"}) - self.assertEqual(result, "mass scanner") + self.assertEqual(result, IpReputation.MASS_SCANNER) def test_preserves_other_reputations(self): result = correct_ip_reputation("1.2.3.4", "bot", {"1.2.3.4"}) @@ -539,10 +540,10 @@ def test_heralding_counts_login_attempts(self): def test_corrects_ip_reputation(self): MassScanner.objects.create(ip_address="8.8.8.8") - hits = [self._create_hit(src_ip="8.8.8.8", ip_rep="known attacker")] + hits = [self._create_hit(src_ip="8.8.8.8", ip_rep=IpReputation.KNOWN_ATTACKER)] iocs = iocs_from_hits(hits) ioc = iocs[0] - self.assertEqual(ioc.ip_reputation, "mass scanner") + self.assertEqual(ioc.ip_reputation, IpReputation.MASS_SCANNER) def test_empty_hits_returns_empty_list(self): iocs = iocs_from_hits([]) diff --git a/tests/test_ioc_processor.py b/tests/test_ioc_processor.py index 1a7d1812..1c958b26 100644 --- a/tests/test_ioc_processor.py +++ b/tests/test_ioc_processor.py @@ -3,6 +3,7 @@ from greedybear.consts import PAYLOAD_REQUEST, SCANNER from greedybear.cronjobs.extraction.ioc_processor import IocProcessor +from greedybear.enums import IpReputation from greedybear.models import IocType from . import ExtractionTestCase @@ -272,30 +273,30 @@ def test_first_seen_not_advanced(self): def test_preserves_reputation_when_new_is_empty(self): """Existing ip_reputation must not be overwritten by an empty value.""" - existing = self._create_mock_ioc(ip_reputation="mass scanner") + existing = self._create_mock_ioc(ip_reputation=IpReputation.MASS_SCANNER) new = self._create_mock_ioc(ip_reputation="") result = self.processor._merge_iocs(existing, new) - self.assertEqual(result.ip_reputation, "mass scanner") + self.assertEqual(result.ip_reputation, IpReputation.MASS_SCANNER) def test_preserves_reputation_when_existing_is_set(self): """Existing ip_reputation must not be overwritten even if new has a value.""" - existing = self._create_mock_ioc(ip_reputation="tor exit node") - new = self._create_mock_ioc(ip_reputation="mass scanner") + existing = self._create_mock_ioc(ip_reputation=IpReputation.TOR_EXIT_NODE) + new = self._create_mock_ioc(ip_reputation=IpReputation.MASS_SCANNER) result = self.processor._merge_iocs(existing, new) - self.assertEqual(result.ip_reputation, "tor exit node") + self.assertEqual(result.ip_reputation, IpReputation.TOR_EXIT_NODE) def test_fills_reputation_when_existing_is_empty(self): """Empty existing ip_reputation should be filled by a non-empty new value.""" existing = self._create_mock_ioc(ip_reputation="") - new = self._create_mock_ioc(ip_reputation="mass scanner") + new = self._create_mock_ioc(ip_reputation=IpReputation.MASS_SCANNER) result = self.processor._merge_iocs(existing, new) - self.assertEqual(result.ip_reputation, "mass scanner") + self.assertEqual(result.ip_reputation, IpReputation.MASS_SCANNER) def test_handles_empty_urls_and_ports(self): existing = self._create_mock_ioc(related_urls=[], destination_ports=[]) diff --git a/tests/test_ioc_repository.py b/tests/test_ioc_repository.py index 31a10672..d88a7e82 100644 --- a/tests/test_ioc_repository.py +++ b/tests/test_ioc_repository.py @@ -4,6 +4,7 @@ from django.db import IntegrityError, transaction from greedybear.cronjobs.repositories import IocRepository +from greedybear.enums import IpReputation from greedybear.models import IOC, GeneralHoneypot from . import CustomTestCase @@ -635,12 +636,12 @@ def test_delete_old_iocs_returns_zero_when_none_old(self): def test_update_ioc_reputation_updates_existing(self): IOC.objects.create(name="1.2.3.4", type="ip", ip_reputation="") - result = self.repo.update_ioc_reputation("1.2.3.4", "mass scanner") + result = self.repo.update_ioc_reputation("1.2.3.4", IpReputation.MASS_SCANNER) self.assertTrue(result) updated = IOC.objects.get(name="1.2.3.4") - self.assertEqual(updated.ip_reputation, "mass scanner") + self.assertEqual(updated.ip_reputation, IpReputation.MASS_SCANNER) def test_update_ioc_reputation_returns_false_for_missing(self): - result = self.repo.update_ioc_reputation("9.9.9.9", "mass scanner") + result = self.repo.update_ioc_reputation("9.9.9.9", IpReputation.MASS_SCANNER) self.assertFalse(result) diff --git a/tests/test_models.py b/tests/test_models.py index 011f8c58..99811c45 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,4 @@ +from greedybear.enums import IpReputation from greedybear.models import IocType, Statistics, Tag, ViewType from . import CustomTestCase @@ -26,7 +27,7 @@ def test_ioc_model(self): self.assertEqual(self.ioc.recurrence_probability, 0.1) self.assertEqual(self.ioc.expected_interactions, 11.1) - self.assertEqual(self.ioc_2.ip_reputation, "mass scanner") + self.assertEqual(self.ioc_2.ip_reputation, IpReputation.MASS_SCANNER) self.assertIn(self.heralding, self.ioc.general_honeypot.all()) self.assertIn(self.ciscoasa, self.ioc.general_honeypot.all()) diff --git a/tests/test_reverse_dns.py b/tests/test_reverse_dns.py index 1ed8334b..cebcebc5 100644 --- a/tests/test_reverse_dns.py +++ b/tests/test_reverse_dns.py @@ -4,6 +4,7 @@ from greedybear.cronjobs import reverse_dns as reverse_dns_module from greedybear.cronjobs.reverse_dns import ReverseDNSCron +from greedybear.enums import IpReputation from greedybear.models import IOC, IocType, Tag from . import CustomTestCase @@ -66,7 +67,7 @@ def test_skips_already_tagged_ips(self): def test_skips_ips_with_existing_reputation(self): """IPs that already have a reputation should not be checked.""" - IOC.objects.filter(name=self.candidate_ioc.name).update(ip_reputation="mass scanner") + IOC.objects.filter(name=self.candidate_ioc.name).update(ip_reputation=IpReputation.MASS_SCANNER) with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")) as mock_resolve: self.cron.run() @@ -137,7 +138,7 @@ def test_updates_reputation_on_scanner_match(self): with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("probe.censys.io")): self.cron.run() - self.mock_ioc_repo.update_ioc_reputation.assert_called_with(self.candidate_ioc.name, "mass scanner") + self.mock_ioc_repo.update_ioc_reputation.assert_called_with(self.candidate_ioc.name, IpReputation.MASS_SCANNER) def test_no_reputation_update_on_non_scanner_ptr(self): """Non-scanner PTR records should not cause reputation updates.""" @@ -364,7 +365,7 @@ def test_update_ioc_success(self): self.cron._update_ioc("1.2.3.4") - self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("1.2.3.4", "mass scanner") + self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("1.2.3.4", IpReputation.MASS_SCANNER) self.cron.log.info.assert_called_once() def test_update_ioc_not_found(self): @@ -372,5 +373,5 @@ def test_update_ioc_not_found(self): self.cron._update_ioc("9.9.9.9") - self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("9.9.9.9", "mass scanner") + self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("9.9.9.9", IpReputation.MASS_SCANNER) self.cron.log.info.assert_not_called() diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 10e4a000..5156bdcd 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -5,6 +5,7 @@ from api.serializers import FeedsRequestSerializer, FeedsResponseSerializer, parse_feed_types from greedybear.consts import PAYLOAD_REQUEST, SCANNER +from greedybear.enums import IpReputation from greedybear.models import IOC, GeneralHoneypot from tests import CustomTestCase @@ -46,13 +47,13 @@ def test_valid_fields(self): "min_days_seen": [str(n) for n in [1, 2, 4, 8, 16]], "include_reputation": [ [], - ["known attacker"], - ["known attacker", "mass scanner"], + [IpReputation.KNOWN_ATTACKER], + [IpReputation.KNOWN_ATTACKER, IpReputation.MASS_SCANNER], ], "exclude_reputation": [ [], - ["known attacker"], - ["known attacker", "mass scanner"], + [IpReputation.KNOWN_ATTACKER], + [IpReputation.KNOWN_ATTACKER, IpReputation.MASS_SCANNER], ], "feed_size": [str(n) for n in [100, 200, 5000, 10_000_000]], "ordering": [field.name for field in IOC._meta.get_fields()], @@ -234,7 +235,7 @@ def test_valid_fields(self): "last_seen": "2023-03-21", "attack_count": "5", "interaction_count": "50", - "ip_reputation": "known attacker", + "ip_reputation": IpReputation.KNOWN_ATTACKER, "firehol_categories": [], "asn": "8400", "destination_port_count": "14", diff --git a/tests/test_tor.py b/tests/test_tor.py index 7b403a67..3e3d508b 100644 --- a/tests/test_tor.py +++ b/tests/test_tor.py @@ -4,6 +4,7 @@ from greedybear.cronjobs.repositories.tor import TorRepository from greedybear.cronjobs.tor_exit_nodes import TorExitNodesCron +from greedybear.enums import IpReputation from tests import CustomTestCase @@ -26,7 +27,7 @@ def test_get_or_create_new_tor_node(self, mock_get_or_create): # Assert self.assertTrue(created) - mock_get_or_create.assert_called_once_with(ip_address="1.2.3.4", defaults={"reason": "tor exit node"}) + mock_get_or_create.assert_called_once_with(ip_address="1.2.3.4", defaults={"reason": IpReputation.TOR_EXIT_NODE}) @patch("greedybear.models.TorExitNode.objects.get_or_create") def test_get_or_create_existing_tor_node(self, mock_get_or_create): @@ -94,4 +95,4 @@ def test_update_old_ioc(self, mock_is_valid): self.cron._update_old_ioc("1.2.3.4") # Assert - self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("1.2.3.4", "tor exit node") + self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("1.2.3.4", IpReputation.TOR_EXIT_NODE) From 4a20eb27f8269beb26b665e286d046c2f89b00c3 Mon Sep 17 00:00:00 2001 From: Varun chauhan <115783538+chauhan-varun@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:15:37 +0530 Subject: [PATCH 062/109] Add client-side validation to enrichment lookup. Closes #1023 (#1031) * feat: add client-side validation for IPv4, IPv6, and domain names in EnrichmentLookup component * test: update EnrichmentLookup integration test for client-side validation of invalid queries * style: simplify regex and validation logic in EnrichmentLookup component and tests * feat: enhance IP address validation in EnrichmentLookup component and update integration tests for comprehensive invalid input handling * refactor: simplify validation logic in EnrichmentLookup component to improve readability and update integration tests for invalid input scenarios * feat: Allow underscores in domain validation and add a test case for domains containing underscores. --- .../components/dashboard/EnrichmentLookup.jsx | 35 ++++++++++ .../EnrichmentLookup.integration.test.jsx | 70 ++++++++++++++----- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/dashboard/EnrichmentLookup.jsx b/frontend/src/components/dashboard/EnrichmentLookup.jsx index 9f43fb49..f2f03f69 100644 --- a/frontend/src/components/dashboard/EnrichmentLookup.jsx +++ b/frontend/src/components/dashboard/EnrichmentLookup.jsx @@ -23,6 +23,33 @@ const initialValues = { query: "", }; +// Very simple, human-readable checks: +// - only allow characters that make sense for IPs/domains +// - and make sure the value at least "looks like" an IP or a domain. +// The backend still performs strict validation. +const validCharRegex = /^[a-zA-Z0-9.:_-]+$/; + +const looksLikeIp = (value) => { + // digits, dots and/or colons, and at least one dot or colon + if (!/^[0-9.:]+$/.test(value)) return false; + return /[.:]/.test(value); +}; + +const looksLikeDomain = (value) => { + // letters/digits/dot/hyphen/underscore, at least one dot, and no leading/trailing dot or hyphen + if (!/^[a-zA-Z0-9._-]+$/.test(value)) return false; + if (!value.includes(".")) return false; + if (/^[-.]/.test(value) || /[-.]$/.test(value)) return false; + return true; +}; + +const isValidQuery = (value) => { + const q = value.trim(); + if (!q) return false; + if (!validCharRegex.test(q)) return false; + return looksLikeIp(q) || looksLikeDomain(q); +}; + export default function EnrichmentLookup() { const [result, setResult] = React.useState(null); @@ -54,6 +81,14 @@ export default function EnrichmentLookup() { return; } + if (!isValidQuery(values.query)) { + setError( + "Please enter a value that looks like an IP or domain (e.g., 192.168.1.1 or example.com).", + ); + setSubmitting(false); + return; + } + try { const resp = await axios.get(ENRICHMENT_URI, { params: { query: values.query.trim() }, diff --git a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx index 1e060958..1a357b3d 100644 --- a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx +++ b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx @@ -220,7 +220,7 @@ describe("Enrichment Lookup Integration Tests", () => { }); }); - test("look up an IP with authentication - validation error", async () => { + test("client-side validation prevents obvious invalid queries", async () => { const user = userEvent.setup(); // Mock authenticated state @@ -228,16 +228,6 @@ describe("Enrichment Lookup Integration Tests", () => { selector({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }), ); - // Mock API validation error - const errorMessage = "Observable is not a valid IP"; - axios.get.mockRejectedValue({ - response: { - data: { - non_field_errors: [errorMessage], - }, - }, - }); - render( @@ -248,18 +238,62 @@ describe("Enrichment Lookup Integration Tests", () => { const inputElement = screen.getByLabelText("IP Address or Domain:"); const submitButton = screen.getByRole("button", { name: /Search/i }); - // Search for an invalid IP - await user.type(inputElement, "invalid-ip-address"); - await user.click(submitButton); + const invalidInputs = [ + "invalid ip", // space + "http://example.com", // protocol + "exa mple.com", // space in domain + "!!!", // invalid characters + "justtext", // no dot, not IP-like + ]; - // Verify API was called + for (const value of invalidInputs) { + await user.clear(inputElement); + await user.type(inputElement, value); + await user.click(submitButton); + + expect( + screen.getByText( + /Please enter a value that looks like an IP or domain/i, + ), + ).toBeInTheDocument(); + } + + // Verify API was NOT called due to client-side validation await waitFor(() => { - expect(axios.get).toHaveBeenCalled(); + expect(axios.get).not.toHaveBeenCalled(); + }); + }); + + test("accepts domains with underscores (e.g., service records)", async () => { + const user = userEvent.setup(); + + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }), + ); + + axios.get.mockResolvedValue({ + data: { found: false, query: "_acme-challenge.example.com", ioc: null }, }); - // Verify validation error message is displayed + render( + + + , + ); + + const inputElement = screen.getByLabelText("IP Address or Domain:"); + const submitButton = screen.getByRole("button", { name: /Search/i }); + + // Search for a domain starting with an underscore + await user.type(inputElement, "_acme-challenge.example.com"); + await user.click(submitButton); + + // Verify API WAS called (validation passed) await waitFor(() => { - expect(screen.getByText(errorMessage)).toBeInTheDocument(); + expect(axios.get).toHaveBeenCalledWith(ENRICHMENT_URI, { + params: { query: "_acme-challenge.example.com" }, + headers: { "Content-Type": "application/json" }, + }); }); }); From 0a47a1cf587461f7085288bb58898d6c2c719ff5 Mon Sep 17 00:00:00 2001 From: SANCHIT KUMAR Date: Tue, 17 Mar 2026 21:35:04 +0530 Subject: [PATCH 063/109] fix: normalize_credential_field missing truncation. Closes #1029 (#1033) * fix: truncate credential fields to 256 chars to prevent DataError on extraction Signed-off-by: Sanchit2662 * fix: strip partial [NUL] sequences at truncation boundary Signed-off-by: Sanchit2662 * fix: simplify normalize_credential_field and add short string test Signed-off-by: Sanchit2662 --------- Signed-off-by: Sanchit2662 --- greedybear/cronjobs/extraction/strategies/cowrie.py | 7 ++++--- tests/test_cowrie_extraction.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/greedybear/cronjobs/extraction/strategies/cowrie.py b/greedybear/cronjobs/extraction/strategies/cowrie.py index fe741de2..e95a3cd9 100644 --- a/greedybear/cronjobs/extraction/strategies/cowrie.py +++ b/greedybear/cronjobs/extraction/strategies/cowrie.py @@ -55,15 +55,16 @@ def normalize_command(message: str) -> str: def normalize_credential_field(field: str) -> str: """ - Normalize credential fields by replacing null characters. + Normalize credential fields by replacing null characters and truncating. Args: field: Credential field string Returns: - Normalized credential field + Normalized credential field, truncated to 256 characters. """ - return field.replace("\x00", "[NUL]") + # Truncate to 256 chars to match Credential model field max_length + return field.replace("\x00", "[NUL]")[:256] class CowrieExtractionStrategy(BaseExtractionStrategy): diff --git a/tests/test_cowrie_extraction.py b/tests/test_cowrie_extraction.py index 50098ed8..c8bcd707 100644 --- a/tests/test_cowrie_extraction.py +++ b/tests/test_cowrie_extraction.py @@ -74,6 +74,19 @@ def test_normalize_credential_field_clean(self): result = normalize_credential_field("admin") self.assertEqual(result, "admin") + def test_normalize_credential_field_truncation(self): + """Test credential field truncation to 256 characters.""" + long_field = "A" * 300 + result = normalize_credential_field(long_field) + self.assertEqual(len(result), 256) + self.assertTrue(result.startswith("A")) + + def test_normalize_credential_field_short_not_truncated(self): + """Test that short strings are not truncated.""" + short_field = "password123" + result = normalize_credential_field(short_field) + self.assertEqual(result, short_field) + class TestCowrieExtractionStrategy(ExtractionTestCase): """Test CowrieExtractionStrategy class.""" From 3cfd73afe2189f46625c6849ef252e6f8f6b4c85 Mon Sep 17 00:00:00 2001 From: Manik Date: Tue, 17 Mar 2026 21:49:30 +0530 Subject: [PATCH 064/109] tests: add test coverage for utilities in greedybear/utils.py. Closes #1043 (#1047) * test: add coverage for utils.py * style: fix ruff formatting issues --- tests/test_utils.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..f7bd7835 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,47 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. + +from django.test import SimpleTestCase + +from greedybear.utils import is_ip_address, is_sha256hash, is_valid_domain + + +class UtilsTestCase(SimpleTestCase): + def test_is_ip_address(self): + # Valid IPv4 + self.assertTrue(is_ip_address("192.168.1.1")) + self.assertTrue(is_ip_address("8.8.8.8")) + # Valid IPv6 + self.assertTrue(is_ip_address("2001:0db8:85a3:0000:0000:8a2e:0370:7334")) + self.assertTrue(is_ip_address("::1")) + # Invalid IP + self.assertFalse(is_ip_address("256.256.256.256")) + self.assertFalse(is_ip_address("not_an_ip")) + self.assertFalse(is_ip_address("")) + + def test_is_valid_domain(self): + # Valid domains + self.assertTrue(is_valid_domain("example.com")) + self.assertTrue(is_valid_domain("sub.example.co.uk")) + self.assertTrue(is_valid_domain("valid-domain.org")) + + # Invalid domains (empty) + self.assertFalse(is_valid_domain("")) + + # Invalid domains (STIX injection characters) + self.assertFalse(is_valid_domain("example.com'")) + self.assertFalse(is_valid_domain('example.com"')) + self.assertFalse(is_valid_domain("example.com\\")) + self.assertFalse(is_valid_domain("example.com\n")) + self.assertFalse(is_valid_domain("example.com\r")) + + def test_is_sha256hash(self): + # Valid SHA-256 + self.assertTrue(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")) + self.assertTrue(is_sha256hash("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855")) + # Invalid SHA-256 + self.assertFalse(is_sha256hash("not_a_hash")) + self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85")) # 63 chars + self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8555")) # 65 chars + self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4g49b934ca495991b7852b855")) # Invalid char 'g' + self.assertFalse(is_sha256hash("")) From bb3eb70cdce06918f089657b93ee1460559577ac Mon Sep 17 00:00:00 2001 From: Rahul Guwani Date: Tue, 17 Mar 2026 21:57:59 +0530 Subject: [PATCH 065/109] fix: replace socket.inet_aton with ipaddress.ip_address to support IPv6 sources. Closes #1055 (#1064) Co-authored-by: rahul-software-dev <24f3003169@ds.study.iitm.ac.in> --- api/views/cowrie_session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index ad872327..7bc8be4c 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -1,7 +1,7 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. +import ipaddress import logging -import socket from certego_saas.apps.auth.backend import CookieTokenAuthentication from django.conf import settings @@ -112,7 +112,7 @@ def cowrie_session_view(request): unique_commands = {s.commands for s in sessions if s.commands} response_data["commands"] = sorted("\n".join(cmd.commands) for cmd in unique_commands) - response_data["sources"] = sorted({s.source.name for s in sessions}, key=socket.inet_aton) + response_data["sources"] = sorted({s.source.name for s in sessions}, key=lambda ip: ipaddress.ip_address(ip)) if include_credentials: response_data["credentials"] = sorted({str(c) for s in sessions for c in s.credentials.all()}) if include_session_data: From cbafa4d59360d0e4442488fbd3ae8b7dc627ad9b Mon Sep 17 00:00:00 2001 From: Abhijeet Sapar <139008505+Abhijeet17o@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:34:42 +0530 Subject: [PATCH 066/109] fix: normalize honeypot membership check to avoid case-drift re-adds Closes #1063 (#1068) --- greedybear/cronjobs/repositories/ioc.py | 7 ++++--- tests/test_ioc_repository.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/greedybear/cronjobs/repositories/ioc.py b/greedybear/cronjobs/repositories/ioc.py index f3254bf3..0888c1bb 100644 --- a/greedybear/cronjobs/repositories/ioc.py +++ b/greedybear/cronjobs/repositories/ioc.py @@ -35,10 +35,11 @@ def add_honeypot_to_ioc(self, honeypot_name: str, ioc: IOC) -> IOC: Returns: The updated IOC instance. """ - honeypot_set = {hp.name for hp in ioc.general_honeypot.all()} - if honeypot_name not in honeypot_set: + normalized_name = self._normalize_name(honeypot_name) + honeypot_set = {self._normalize_name(hp.name) for hp in ioc.general_honeypot.all()} + if normalized_name not in honeypot_set: self.log.debug(f"adding honeypot {honeypot_name} to IoC {ioc}") - honeypot = self._honeypot_cache.get(self._normalize_name(honeypot_name)) + honeypot = self._honeypot_cache.get(normalized_name) if honeypot is not None: ioc.general_honeypot.add(honeypot) else: diff --git a/tests/test_ioc_repository.py b/tests/test_ioc_repository.py index d88a7e82..74a1fb4e 100644 --- a/tests/test_ioc_repository.py +++ b/tests/test_ioc_repository.py @@ -123,6 +123,20 @@ def test_add_honeypot_to_ioc_idempotent(self): self.assertEqual(result.general_honeypot.count(), initial_count) self.assertEqual(ioc.general_honeypot.count(), 1) + def test_add_honeypot_to_ioc_idempotent_case_insensitive(self): + ioc = IOC.objects.create(name="1.2.3.5", type="ip") + honeypot = GeneralHoneypot.objects.get(name="Log4pot") + ioc.general_honeypot.add(honeypot) + + # Fetch through repository so general_honeypot is prefetched; with normalized + # membership check this should not attempt another add query. + ioc_fetched = self.repo.get_ioc_by_name("1.2.3.5") + with self.assertNumQueries(0): + result = self.repo.add_honeypot_to_ioc("Log4Pot", ioc_fetched) + + self.assertEqual(result.general_honeypot.count(), 1) + self.assertIn(honeypot, result.general_honeypot.all()) + def test_add_honeypot_to_ioc_multiple_honeypots(self): ioc = IOC.objects.create(name="1.2.3.4", type="ip") hp1 = GeneralHoneypot.objects.get(name="Cowrie") From 7c4a6897881d4abd6147577923cdd154bc80ca9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:14:26 +0100 Subject: [PATCH 067/109] build(deps): bump library/nginx in /docker (#1075) Bumps library/nginx from 1.29.5-alpine to 1.29.6-alpine. --- updated-dependencies: - dependency-name: library/nginx dependency-version: 1.29.6-alpine 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> --- docker/Dockerfile_nginx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile_nginx b/docker/Dockerfile_nginx index 971b9a2a..957be4bb 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -1,4 +1,4 @@ -FROM library/nginx:1.29.5-alpine +FROM library/nginx:1.29.6-alpine ENV NGINX_LOG_DIR=/var/log/nginx From 53b681f1414a1119834089fc7045a7d4dc9e1dab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:15:17 +0100 Subject: [PATCH 068/109] build(deps): bump slack-sdk from 3.40.1 to 3.41.0 in /requirements (#1076) Bumps [slack-sdk](https://github.com/slackapi/python-slack-sdk) from 3.40.1 to 3.41.0. - [Release notes](https://github.com/slackapi/python-slack-sdk/releases) - [Commits](https://github.com/slackapi/python-slack-sdk/compare/v3.40.1...v3.41.0) --- updated-dependencies: - dependency-name: slack-sdk dependency-version: 3.41.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> --- requirements/project-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt index 5d3b0baa..bdbb9fef 100644 --- a/requirements/project-requirements.txt +++ b/requirements/project-requirements.txt @@ -12,7 +12,7 @@ django-ses==4.7.2 psycopg2-binary==2.9.11 certego-saas==0.7.11 -slack-sdk==3.40.1 +slack-sdk==3.41.0 gunicorn==25.1.0 From d50e3b786e12ff19555ba06c41a3dd76e2ca6269 Mon Sep 17 00:00:00 2001 From: Deepanshu <144600350+Deepanshu1230@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:54:27 +0530 Subject: [PATCH 069/109] feat: persist Feeds page filters in URL query params. Closes #1017 (#1035) * feat: persist Feeds page filters in URL query params * fix:linting and remove useCallback wrapper and fix ESlint error * fix: remove unknown eslint rule reference in Feeds * fix: handle sort and eslint ignore * fixing the Redundant Call of formik --- frontend/src/components/feeds/Feeds.jsx | 104 ++++++++++++----- .../tests/components/feeds/Feeds.test.jsx | 105 +++++++++++++++++- 2 files changed, 178 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/feeds/Feeds.jsx b/frontend/src/components/feeds/Feeds.jsx index fc018c3f..ed8cdbb1 100644 --- a/frontend/src/components/feeds/Feeds.jsx +++ b/frontend/src/components/feeds/Feeds.jsx @@ -3,7 +3,7 @@ import { Container, Button, Col, Label, FormGroup, Row } from "reactstrap"; import { VscJson } from "react-icons/vsc"; import { TbLicense } from "react-icons/tb"; import { MdFilterAltOff } from "react-icons/md"; -import { useLocation } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router-dom"; import { FEEDS_BASE_URI, GENERAL_HONEYPOT_URI } from "../../constants/api"; import { ContentSection, @@ -99,17 +99,40 @@ function FeedsTable({ tableParams, onDataLoad, onSortChange }) { export default function Feeds() { console.debug("Feeds rendered!"); console.debug("Feeds-DEFAULT_VALUES", DEFAULT_VALUES); + + const [searchParams, setSearchParams] = useSearchParams(); const formikRef = React.useRef(null); - const [feedsState, setFeedsState] = React.useState({ - url: `${FEEDS_BASE_URI}/${DEFAULT_VALUES.feeds_type}/${DEFAULT_VALUES.attack_type}/${DEFAULT_VALUES.prioritize}.json`, - tableParams: { - feed_type: DEFAULT_VALUES.feeds_type, - attack_type: DEFAULT_VALUES.attack_type, - ioc_type: DEFAULT_VALUES.ioc_type, - prioritize: DEFAULT_VALUES.prioritize, - }, - tableKey: 0, + const initialSearchParams = React.useRef(searchParams); + + const initialValues = React.useMemo( + () => ({ + feeds_type: + initialSearchParams.current.get("feed_type") || + DEFAULT_VALUES.feeds_type, + attack_type: + initialSearchParams.current.get("attack_type") || + DEFAULT_VALUES.attack_type, + ioc_type: + initialSearchParams.current.get("ioc_type") || DEFAULT_VALUES.ioc_type, + prioritize: + initialSearchParams.current.get("prioritize") || + DEFAULT_VALUES.prioritize, + }), + [], + ); + const [feedsState, setFeedsState] = React.useState(() => { + const values = initialValues; + return { + url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, + tableParams: { + feed_type: values.feeds_type, + attack_type: values.attack_type, + ioc_type: values.ioc_type, + prioritize: values.prioritize, + }, + tableKey: 0, + }; }); // feedsData is lifted from FeedsTable so we can show the count in the header @@ -137,7 +160,24 @@ export default function Feeds() { [honeypots], ); - // reset the prioritize dropdown to "recent" + // Update URL query params to reflect current filter state + // Uses replace: true so filter changes don't pollute browser history + const updateSearchParams = React.useCallback( + (values) => { + const params = {}; + if (values.feeds_type !== DEFAULT_VALUES.feeds_type) + params.feed_type = values.feeds_type; + if (values.attack_type !== DEFAULT_VALUES.attack_type) + params.attack_type = values.attack_type; + if (values.ioc_type !== DEFAULT_VALUES.ioc_type) + params.ioc_type = values.ioc_type; + if (values.prioritize !== DEFAULT_VALUES.prioritize) + params.prioritize = values.prioritize; + setSearchParams(params, { replace: true }); + }, + [setSearchParams], + ); + const handleSortChange = React.useCallback(async () => { const formik = formikRef.current; if (!formik) return; @@ -148,22 +188,26 @@ export default function Feeds() { }, []); // callbacks - const onSubmit = React.useCallback((values) => { - try { - setFeedsState((prev) => ({ - url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, - tableParams: { - feed_type: values.feeds_type, - attack_type: values.attack_type, - ioc_type: values.ioc_type, - prioritize: values.prioritize, - }, - tableKey: prev.tableKey + 1, - })); - } catch (e) { - console.debug(e); - } - }, []); + const onSubmit = React.useCallback( + (values) => { + try { + setFeedsState((prev) => ({ + url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, + tableParams: { + feed_type: values.feeds_type, + attack_type: values.attack_type, + ioc_type: values.ioc_type, + prioritize: values.prioritize, + }, + tableKey: prev.tableKey + 1, + })); + updateSearchParams(values); + } catch (e) { + console.debug(e); + } + }, + [updateSearchParams], + ); return ( @@ -191,7 +235,7 @@ export default function Feeds() { ( @@ -328,7 +372,9 @@ export default function Feeds() { title="Reset filters" aria-label="Reset filters" onClick={() => { - formikRef.current?.resetForm(); + formikRef.current?.resetForm({ + values: DEFAULT_VALUES, + }); onSubmit(DEFAULT_VALUES); }} > diff --git a/frontend/tests/components/feeds/Feeds.test.jsx b/frontend/tests/components/feeds/Feeds.test.jsx index bf0ec948..c53d6b17 100644 --- a/frontend/tests/components/feeds/Feeds.test.jsx +++ b/frontend/tests/components/feeds/Feeds.test.jsx @@ -1,7 +1,7 @@ import React from "react"; import "@testing-library/jest-dom"; import { render, screen, waitFor } from "@testing-library/react"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter, MemoryRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import Feeds from "../../../src/components/feeds/Feeds"; @@ -128,7 +128,7 @@ describe("Feeds component", () => { const buttonRawData = screen.getByRole("link", { name: /Raw data/i }); expect(buttonRawData).toHaveAttribute( "href", - "/api/feeds/all/all/recent.json", + "/api/feeds/all/all/recent.json?ioc_type=all", ); await user.selectOptions(feedTypeSelectElement, "cowrie"); @@ -355,4 +355,105 @@ describe("Feeds component", () => { }); }); }); + + // ─── NEW: URL query param persistence tests ─────────────────────────────── + + describe("URL query param persistence", () => { + test("initializes filters from URL query params on page load", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByLabelText("Attack type:")).toHaveValue("scanner"); + expect(screen.getByLabelText("IOC type:")).toHaveValue("ip"); + expect(screen.getByLabelText("Prioritize:")).toHaveValue("persistent"); + }); + }); + + test("updates raw data URL when a filter is changed", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const attackTypeSelect = screen.getByLabelText("Attack type:"); + await user.selectOptions(attackTypeSelect, "scanner"); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /Raw data/i })).toHaveAttribute( + "href", + "/api/feeds/all/scanner/recent.json?ioc_type=all", + ); + }); + }); + + test("raw data URL starts with defaults when no query params present", () => { + render( + + + , + ); + + expect(screen.getByRole("link", { name: /Raw data/i })).toHaveAttribute( + "href", + "/api/feeds/all/all/recent.json?ioc_type=all", + ); + }); + test("clears filters when reset button is clicked after loading from query params", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const resetButton = screen.getByTitle("Reset filters"); + const attackTypeSelect = screen.getByLabelText("Attack type:"); + const iocTypeSelect = screen.getByLabelText("IOC type:"); + + await waitFor(() => { + expect(attackTypeSelect).toHaveValue("scanner"); + expect(iocTypeSelect).toHaveValue("ip"); + expect(resetButton).not.toBeDisabled(); + }); + + await user.click(resetButton); + + await waitFor(() => { + expect(attackTypeSelect).toHaveValue("all"); + expect(iocTypeSelect).toHaveValue("all"); + expect(resetButton).toBeDisabled(); + }); + }); + + test("raw data URL reflects filters loaded from query params", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /Raw data/i })).toHaveAttribute( + "href", + "/api/feeds/cowrie/scanner/persistent.json?ioc_type=ip", + ); + }); + }); + }); }); From 88ea858360c9482e92b1a9e406dae5ff36404e21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:49:05 +0100 Subject: [PATCH 070/109] build(deps): bump croniter from 6.0.0 to 6.2.2 in /requirements (#1077) Bumps [croniter](https://github.com/pallets-eco/croniter) from 6.0.0 to 6.2.2. - [Changelog](https://github.com/pallets-eco/croniter/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pallets-eco/croniter/compare/6.0.0...6.2.2) --- updated-dependencies: - dependency-name: croniter dependency-version: 6.2.2 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> --- requirements/project-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt index bdbb9fef..f8343321 100644 --- a/requirements/project-requirements.txt +++ b/requirements/project-requirements.txt @@ -1,5 +1,5 @@ django-q2==1.9.0 -croniter==6.0.0 +croniter==6.2.2 # if you change this, update the documentation elasticsearch==9.3.0 From 7e29cebdb0529f76b1b590576c1deeca06806db9 Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Wed, 18 Mar 2026 22:52:48 +0545 Subject: [PATCH 071/109] enh: Log feature importances after Random Forest training. Closes: #1050 (#1065) Signed-off-by: Drona Raj Gyawali --- greedybear/cronjobs/scoring/random_forest.py | 9 ++++ tests/test_rf_models.py | 57 ++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/greedybear/cronjobs/scoring/random_forest.py b/greedybear/cronjobs/scoring/random_forest.py index 72c037ee..2cebd710 100755 --- a/greedybear/cronjobs/scoring/random_forest.py +++ b/greedybear/cronjobs/scoring/random_forest.py @@ -52,6 +52,15 @@ def train(self, df: pd.DataFrame) -> None: self.model = self.untrained_model.fit(x_train, y_train) self.log.info(f"finished training {self.name} - recall AUC: {self.recall_auc(x_test, y_test):.4f}") + + feature_names = x_train.columns.tolist() + + importances = self.model.feature_importances_ + + sorted_features = sorted(zip(feature_names, importances, strict=False), key=lambda pair: pair[1], reverse=True) + importance_lines = "\n".join(f" {name}: {score:.4f}" for name, score in sorted_features) + + self.log.info(f"Feature importances for {self.name}:\n{importance_lines}") self.save() @property diff --git a/tests/test_rf_models.py b/tests/test_rf_models.py index 3c16cdf2..59576bbc 100644 --- a/tests/test_rf_models.py +++ b/tests/test_rf_models.py @@ -272,3 +272,60 @@ def test_save_training_data_called_on_scorer_failure(self, mock_get_data, mock_g # The critical assertion: save_training_data must be called despite the crash job.save_training_data.assert_called_once() + + +class TestFeatureImportanceLogging(CustomTestCase): + """Test that RFModel logs feature importances after training.""" + + IMPORTANCE_SAMPLE_DATA = pd.DataFrame( + { + "interactions_on_eval_day": [0, 1, 2, 0, 3, 1, 0, 2, 1, 3], + "feature1": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "feature2": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], + "feature3": [122, 12, 0, 14, 87, 34, 56, 78, 90, 11], + "honeypots": [ + "cowrie,dionaea", + "dionaea,glutton", + "cowrie", + "glutton", + "cowrie,dionaea,glutton", + "cowrie", + "dionaea", + "glutton", + "cowrie,dionaea", + "dionaea,glutton", + ], + } + ) + + class MockRFModel(RFModel, Classifier): + def __init__(self): + super().__init__("Mock RF Model", "mock_score") + + @property + def features(self): + return FEATURES + + @property + def untrained_model(self): + mock = Mock() + mock.feature_names_in_ = FEATURES + mock.feature_importances_ = np.array([0.5, 0.3, 0.15, 0.02, 0.02, 0.01]) + mock.fit.return_value = mock + + a = np.zeros((10, 2)) + a[:, 1] = [0.9, 0.8, 0.3, 0.2, 0.1, 0.7, 0.6, 0.4, 0.5, 0.3] + mock.predict_proba.return_value = a + + return mock + + def test_feature_importances_logged(self): + model = self.MockRFModel() + model.log = Mock() + model.save = Mock() + + model.train(self.IMPORTANCE_SAMPLE_DATA) + + logged_messages = [call.args[0] for call in model.log.info.call_args_list] + + self.assertTrue(any("Feature importances for" in msg for msg in logged_messages)) From ab507e70ea190c76ebc684f13f699f277a46373d Mon Sep 17 00:00:00 2001 From: cclts <110119346+cclts@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:58:45 -0500 Subject: [PATCH 072/109] Feature: Extract and store Cowrie file transfer metadata. Closes #848 (#1041) * Add CowrieFileTransfer model and extraction support for file transfer events * Fix lint issues * Apply ruff formatting * merging migration * fix parentheses * Fix migration conflict --- greedybear/consts.py | 2 + .../cronjobs/extraction/strategies/cowrie.py | 20 +++++- .../cronjobs/repositories/cowrie_session.py | 34 ++++++++- .../migrations/0044_cowriefiletransfer.py | 29 ++++++++ greedybear/models.py | 17 +++++ tests/test_cowrie_extraction.py | 69 +++++++++++++++++++ tests/test_cowrie_session_repository.py | 46 ++++++++++++- 7 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 greedybear/migrations/0044_cowriefiletransfer.py diff --git a/greedybear/consts.py b/greedybear/consts.py index 777936fd..43b434c7 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -30,6 +30,8 @@ "username", "password", "t-pot_ip_ext", + "shasum", + "outfile", ] diff --git a/greedybear/cronjobs/extraction/strategies/cowrie.py b/greedybear/cronjobs/extraction/strategies/cowrie.py index e95a3cd9..9406f1aa 100644 --- a/greedybear/cronjobs/extraction/strategies/cowrie.py +++ b/greedybear/cronjobs/extraction/strategies/cowrie.py @@ -174,8 +174,8 @@ def _get_url_downloads(self, hits: list[dict]) -> None: scanner_ip = str(hit["src_ip"]) download_url = str(hit["url"]) - - self.log.info(f"found IP {scanner_ip} downloading from {download_url}") + shasum = hit.get("shasum") + self.log.info(f"found IP {scanner_ip} downloading from {download_url}" + (f" (SHA256: {shasum})" if shasum else "")) # Extract and track download URL if download_url: @@ -269,6 +269,22 @@ def _process_session_hit(self, session_record: CowrieSession, hit: dict, ioc: IO case "cowrie.session.closed": session_record.duration = hit["duration"] + case "cowrie.session.file_download" | "cowrie.session.file_upload": + shasum = hit.get("shasum") + if shasum: + url = hit.get("url", "") + outfile = hit.get("outfile", "") + timestamp = hit["timestamp"] + self.log.info(f"found file with shasum {shasum[:8]}... from {ioc.name}") + + self.session_repo.get_or_create_file_transfer( + session=session_record, + shasum=shasum, + url=url, + outfile=outfile, + timestamp=timestamp, + ) + session_record.interaction_count += 1 def _deduplicate_command_sequence(self, session: CowrieSession) -> bool: diff --git a/greedybear/cronjobs/repositories/cowrie_session.py b/greedybear/cronjobs/repositories/cowrie_session.py index 1f816bba..313c67b5 100644 --- a/greedybear/cronjobs/repositories/cowrie_session.py +++ b/greedybear/cronjobs/repositories/cowrie_session.py @@ -1,6 +1,6 @@ import logging -from greedybear.models import IOC, CommandSequence, CowrieSession +from greedybear.models import IOC, CommandSequence, CowrieFileTransfer, CowrieSession class CowrieSessionRepository: @@ -33,6 +33,38 @@ def get_or_create_session(self, session_id: str, source: IOC) -> CowrieSession: self.log.debug(f"created new session {session_id}" if created else f"{session_id} already exists") return record + def get_or_create_file_transfer(self, session: CowrieSession, shasum: str, url: str, outfile: str, timestamp) -> CowrieFileTransfer: + """ + Create or update a file transfer associated with a Cowrie session. + + If a transfer with the same session and shasum already exists, + its timestamp will be updated to the latest event time. + Otherwise, a new CowrieFileTransfer record will be created. + + Args: + session: The CowrieSession instance the file transfer belongs to. + shasum: SHA256 checksum of the transferred file. + url: Source URL of the file if downloaded by the attacker. + outfile: File path recorded by Cowrie when storing the transferred file on the honeypot. + timestamp: Timestamp of the transfer event. + + Returns: + The created or updated CowrieFileTransfer instance. + """ + transfer, created = CowrieFileTransfer.objects.get_or_create( + session=session, + shasum=shasum, + defaults={ + "url": url, + "outfile": outfile, + "timestamp": timestamp, + }, + ) + if not created: + transfer.timestamp = timestamp + transfer.save() + return transfer + def get_command_sequence_by_hash(self, commands_hash: str) -> CommandSequence | None: """ Retrieve a command sequence by its hash. diff --git a/greedybear/migrations/0044_cowriefiletransfer.py b/greedybear/migrations/0044_cowriefiletransfer.py new file mode 100644 index 00000000..c00ffd8b --- /dev/null +++ b/greedybear/migrations/0044_cowriefiletransfer.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.12 on 2026-03-18 18:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0043_autonomoussystem_remove_ioc_asn_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CowrieFileTransfer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shasum', models.CharField(max_length=64)), + ('url', models.CharField(blank=True, max_length=900)), + ('outfile', models.CharField(blank=True, max_length=256)), + ('timestamp', models.DateTimeField()), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='file_transfers', to='greedybear.cowriesession')), + ], + options={ + 'indexes': [models.Index(fields=['shasum'], name='greedybear__shasum_083f18_idx')], + 'constraints': [models.UniqueConstraint(fields=('shasum', 'session'), name='unique_download_per_session')], + }, + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index db9e9970..0caf3259 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -161,6 +161,23 @@ def __str__(self): return f"Session {hex(self.session_id)[2:]} from {self.source.name}" +class CowrieFileTransfer(models.Model): + session = models.ForeignKey(CowrieSession, on_delete=models.CASCADE, related_name="file_transfers") + shasum = models.CharField(max_length=64) + url = models.CharField(max_length=900, blank=True) + outfile = models.CharField(max_length=256, blank=True) + timestamp = models.DateTimeField(blank=False) + + class Meta: + indexes = [ + models.Index(fields=["shasum"]), + ] + constraints = [models.UniqueConstraint(fields=["shasum", "session"], name="unique_download_per_session")] + + def __str__(self): + return f"{self.shasum[:8]} from session {self.session_id}" + + class Statistics(models.Model): source = models.CharField(max_length=15) view = models.CharField( diff --git a/tests/test_cowrie_extraction.py b/tests/test_cowrie_extraction.py index c8bcd707..c1d66d5b 100644 --- a/tests/test_cowrie_extraction.py +++ b/tests/test_cowrie_extraction.py @@ -291,6 +291,75 @@ def test_process_session_hit_session_closed(self): self.assertEqual(session_record.duration, 10.5) + def test_process_session_hit_file_download_creates_transfer(self): + """Test processing of file download event creates file transfer.""" + session_record = Mock() + session_record.interaction_count = 0 + + hit = { + "eventid": "cowrie.session.file_download", + "timestamp": "2023-01-01T10:00:04", + "shasum": "abc123def456", + "url": "http://malware.com/bad.exe", + "outfile": "/data/cowrie/downloads/bad.exe", + } + ioc = Mock(name="1.2.3.4") + + self.strategy._process_session_hit(session_record, hit, ioc) + + self.mock_session_repo.get_or_create_file_transfer.assert_called_once_with( + session=session_record, + shasum="abc123def456", + url="http://malware.com/bad.exe", + outfile="/data/cowrie/downloads/bad.exe", + timestamp="2023-01-01T10:00:04", + ) + self.assertEqual(session_record.interaction_count, 1) + + def test_process_session_hit_file_upload_creates_transfer(self): + """Test processing of file upload event creates file transfer.""" + session_record = Mock() + session_record.interaction_count = 0 + + hit = { + "eventid": "cowrie.session.file_upload", + "timestamp": "2023-01-01T10:00:04", + "shasum": "deadbeef123456", + "filename": "malware.sh", + "outfile": "/var/lib/cowrie/downloads/deadbeef123456", + } + ioc = Mock(name="1.2.3.4") + + self.strategy._process_session_hit(session_record, hit, ioc) + + self.mock_session_repo.get_or_create_file_transfer.assert_called_once_with( + session=session_record, + shasum="deadbeef123456", + url="", # upload events do not contain URL + outfile="/var/lib/cowrie/downloads/deadbeef123456", + timestamp="2023-01-01T10:00:04", + ) + self.assertEqual(session_record.interaction_count, 1) + + def test_process_session_hit_file_upload_without_shasum(self): + """Test file transfer is skipped when shasum is missing.""" + session_record = Mock() + session_record.interaction_count = 0 + + hit = { + "eventid": "cowrie.session.file_upload", + "timestamp": "2023-01-01T10:00:04", + # no shasum + "url": "http://attacker.com/upload.bin", + "outfile": "/data/cowrie/uploads/upload.bin", + } + ioc = Mock(name="1.2.3.4") + + self.strategy._process_session_hit(session_record, hit, ioc) + + self.mock_session_repo.get_or_create_file_transfer.assert_not_called() + self.assertEqual(session_record.interaction_count, 1) + def test_add_fks_both_exist(self): """Test linking IOCs when both exist.""" scanner_mock = MagicMock() diff --git a/tests/test_cowrie_session_repository.py b/tests/test_cowrie_session_repository.py index 3f245a9c..fe86083a 100644 --- a/tests/test_cowrie_session_repository.py +++ b/tests/test_cowrie_session_repository.py @@ -3,7 +3,7 @@ from django.db import IntegrityError from greedybear.cronjobs.repositories import CowrieSessionRepository -from greedybear.models import IOC, CommandSequence, CowrieSession +from greedybear.models import IOC, CommandSequence, CowrieFileTransfer, CowrieSession from . import CustomTestCase @@ -102,6 +102,50 @@ def test_command_sequence_unique_hash_constraint(self): commands_hash=existing.commands_hash, ) + def test_get_or_create_file_transfer_creates_new(self): + session = self.cowrie_session + + transfer = self.repo.get_or_create_file_transfer( + session=session, + shasum="abc123def456", + url="http://malware.com/bad.exe", + outfile="/data/cowrie/downloads/bad.exe", + timestamp="2023-01-01T10:00:04", + ) + + self.assertIsNotNone(transfer.pk) + self.assertEqual(transfer.session, session) + self.assertEqual(transfer.shasum, "abc123def456") + self.assertEqual(transfer.url, "http://malware.com/bad.exe") + self.assertEqual(transfer.outfile, "/data/cowrie/downloads/bad.exe") + self.assertEqual(transfer.timestamp, "2023-01-01T10:00:04") + + def test_get_or_create_file_transfer_updates_timestamp(self): + session = self.cowrie_session + + existing = CowrieFileTransfer.objects.create( + session=session, + shasum="abc123def456", + url="http://malware.com/bad.exe", + outfile="/data/cowrie/downloads/bad.exe", + timestamp="2023-01-01T10:00:04", + ) + + transfer = self.repo.get_or_create_file_transfer( + session=session, + shasum="abc123def456", + url="http://malware.com/ignored.exe", + outfile="/data/cowrie/downloads/ignored.exe", + timestamp="2023-01-02T10:00:04", + ) + + self.assertEqual(transfer.pk, existing.pk) + self.assertEqual(transfer.timestamp, "2023-01-02T10:00:04") + self.assertEqual( + CowrieFileTransfer.objects.filter(session=session, shasum="abc123def456").count(), + 1, + ) + class TestCowrieSessionRepositoryCleanup(CustomTestCase): """Tests for cleanup-related methods in CowrieSessionRepository.""" From 707ed46fcfe481241e7a5eb0f7a68b5f44dbce8d Mon Sep 17 00:00:00 2001 From: Arnav Vinod Deshpande Date: Thu, 19 Mar 2026 20:31:36 +0530 Subject: [PATCH 073/109] Feature: Implementation of Heralding extraction strategy. Closes #1006 (#1042) * implementation * mocked ioc skip fixed * fixed object rrefining * addin socks5 test * protocol field * test uprades and others * credential normalization and common util for both * lint errors * migration name --- .../extraction/strategies/__init__.py | 1 + .../cronjobs/extraction/strategies/cowrie.py | 15 +- .../cronjobs/extraction/strategies/factory.py | 2 + .../extraction/strategies/heralding.py | 138 +++++++ greedybear/cronjobs/extraction/utils.py | 15 + .../cronjobs/repositories/cowrie_session.py | 16 +- .../migrations/0045_credential_protocol.py | 28 ++ greedybear/models.py | 11 +- tests/__init__.py | 4 +- tests/test_cowrie_session_repository.py | 26 +- tests/test_heralding_strategy.py | 387 ++++++++++++++++++ tests/test_migrations.py | 27 ++ tests/test_models.py | 17 +- 13 files changed, 665 insertions(+), 22 deletions(-) create mode 100644 greedybear/cronjobs/extraction/strategies/heralding.py create mode 100644 greedybear/migrations/0045_credential_protocol.py create mode 100644 tests/test_heralding_strategy.py diff --git a/greedybear/cronjobs/extraction/strategies/__init__.py b/greedybear/cronjobs/extraction/strategies/__init__.py index 3c63af36..af7219aa 100644 --- a/greedybear/cronjobs/extraction/strategies/__init__.py +++ b/greedybear/cronjobs/extraction/strategies/__init__.py @@ -1,4 +1,5 @@ from greedybear.cronjobs.extraction.strategies.base import * from greedybear.cronjobs.extraction.strategies.cowrie import * from greedybear.cronjobs.extraction.strategies.generic import * +from greedybear.cronjobs.extraction.strategies.heralding import * from greedybear.cronjobs.extraction.strategies.tanner import * diff --git a/greedybear/cronjobs/extraction/strategies/cowrie.py b/greedybear/cronjobs/extraction/strategies/cowrie.py index 9406f1aa..aa7b043b 100644 --- a/greedybear/cronjobs/extraction/strategies/cowrie.py +++ b/greedybear/cronjobs/extraction/strategies/cowrie.py @@ -10,6 +10,7 @@ from greedybear.cronjobs.extraction.utils import ( get_ioc_type, iocs_from_hits, + normalize_credential_field, parse_timestamp, threatfox_submission, ) @@ -53,20 +54,6 @@ def normalize_command(message: str) -> str: return message.removeprefix("CMD: ").replace("\x00", "[NUL]")[:1024] -def normalize_credential_field(field: str) -> str: - """ - Normalize credential fields by replacing null characters and truncating. - - Args: - field: Credential field string - - Returns: - Normalized credential field, truncated to 256 characters. - """ - # Truncate to 256 chars to match Credential model field max_length - return field.replace("\x00", "[NUL]")[:256] - - class CowrieExtractionStrategy(BaseExtractionStrategy): """ Extraction strategy for Cowrie SSH/Telnet honeypot. diff --git a/greedybear/cronjobs/extraction/strategies/factory.py b/greedybear/cronjobs/extraction/strategies/factory.py index ee3a8728..94a5e408 100644 --- a/greedybear/cronjobs/extraction/strategies/factory.py +++ b/greedybear/cronjobs/extraction/strategies/factory.py @@ -2,6 +2,7 @@ BaseExtractionStrategy, CowrieExtractionStrategy, GenericExtractionStrategy, + HeraldingExtractionStrategy, TannerExtractionStrategy, ) from greedybear.cronjobs.repositories import IocRepository, SensorRepository @@ -26,6 +27,7 @@ def __init__(self, ioc_repo: IocRepository, sensor_repo: SensorRepository): self.sensor_repo = sensor_repo self._strategies = { "Cowrie": lambda: CowrieExtractionStrategy("Cowrie", self.ioc_repo, self.sensor_repo), + "Heralding": lambda: HeraldingExtractionStrategy("Heralding", self.ioc_repo, self.sensor_repo), "Tanner": lambda: TannerExtractionStrategy("Tanner", self.ioc_repo, self.sensor_repo), } diff --git a/greedybear/cronjobs/extraction/strategies/heralding.py b/greedybear/cronjobs/extraction/strategies/heralding.py new file mode 100644 index 00000000..07848a10 --- /dev/null +++ b/greedybear/cronjobs/extraction/strategies/heralding.py @@ -0,0 +1,138 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +from greedybear.consts import SCANNER +from greedybear.cronjobs.extraction.strategies import BaseExtractionStrategy +from greedybear.cronjobs.extraction.utils import ( + iocs_from_hits, + normalize_credential_field, + threatfox_submission, +) +from greedybear.cronjobs.repositories import IocRepository, SensorRepository +from greedybear.models import Credential + +HERALDING_HONEYPOT = "Heralding" + +# Protocols that Heralding emulates for credential capture. +HERALDING_PROTOCOLS = frozenset( + { + "ssh", + "telnet", + "ftp", + "http", + "https", + "pop3", + "imap", + "smtp", + "vnc", + "socks5", + "postgresql", + "mysql", + "mssql", + "rdp", + } +) + + +class HeraldingExtractionStrategy(BaseExtractionStrategy): + """ + Extraction strategy for Heralding credential-catching honeypot. + + Heralding emulates multiple protocols (SSH, Telnet, FTP, HTTP, etc.) + and captures credential brute-force attempts. This strategy: + - Extracts scanner IPs as IOCs + - Stores credentials with the protocol they targeted + """ + + def __init__( + self, + honeypot: str, + ioc_repo: IocRepository, + sensor_repo: SensorRepository, + ): + super().__init__(honeypot, ioc_repo, sensor_repo) + self.credentials_added = 0 + + def extract_from_hits(self, hits: list[dict]) -> None: + """ + Extract IOCs from Heralding honeypot log hits. + + Processes scanner IPs, then classifies credential-brute-force + attempts and stores protocol-aware credentials. + + Args: + hits: List of Elasticsearch hit dictionaries to process. + """ + self._get_scanners(hits) + self._classify_credential_attacks(hits) + self.log.info(f"added {len(self.ioc_records)} scanners, {self.credentials_added} credentials from {self.honeypot}") + + def _get_scanners(self, hits: list[dict]) -> None: + """Extract scanner IPs from hits.""" + for ioc in iocs_from_hits(hits): + self.log.info(f"found IP {ioc.name} by honeypot {self.honeypot}") + ioc_record = self.ioc_processor.add_ioc( + ioc, + attack_type=SCANNER, + general_honeypot_name=HERALDING_HONEYPOT, + ) + if ioc_record: + self.ioc_records.append(ioc_record) + threatfox_submission(ioc_record, ioc.related_urls, self.log) + + def _classify_credential_attacks(self, hits: list[dict]) -> None: + """ + Classify credential brute-force attempts by protocol and persist credentials. + + Extracts username/password pairs from Heralding hits and stores them + together with the normalized protocol on the Credential model. + Duplicate tuples in the same batch are deduplicated. + + Args: + hits: List of Elasticsearch hit documents. + """ + credentials: set[tuple[str, str, str]] = set() + for hit in hits: + protocol = self._extract_protocol(hit) + if not protocol: + continue + + raw_username = hit.get("username") + raw_password = hit.get("password") + if not raw_username and not raw_password: + continue + + username = normalize_credential_field(raw_username) + password = normalize_credential_field(raw_password) + credentials.add((username, password, protocol)) + + for username, password, protocol in sorted(credentials): + _, created = Credential.objects.get_or_create( + username=username, + password=password, + protocol=protocol, + ) + if created: + self.credentials_added += 1 + self.log.info(f"stored credential for protocol={protocol}") + + def _extract_protocol(self, hit: dict) -> str | None: + """ + Extract and normalise the protocol name from a hit. + + Heralding logs include a ``protocol`` field indicating which + emulated service the attacker connected to. The value is + lower-cased and validated against the known set of Heralding + protocols. + + Args: + hit: Elasticsearch hit document. + + Returns: + Normalised protocol string, or ``None`` if absent/unknown. + """ + raw = hit.get("protocol") + if raw: + normalised = str(raw).strip().lower() + if normalised in HERALDING_PROTOCOLS: + return normalised + return None diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index d168c949..556c63ff 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -27,6 +27,21 @@ def parse_timestamp(timestamp: str) -> datetime: return datetime.fromisoformat(timestamp).replace(tzinfo=None) +def normalize_credential_field(value: object, max_length: int = 256) -> str: + """ + Normalize a credential field from untrusted input. + + Args: + value: Raw field value. + max_length: Maximum length allowed by the model field. + + Returns: + Sanitized credential field string. + """ + text = "" if value is None else str(value) + return text.replace("\x00", "[NUL]")[:max_length] + + def is_whatsmyip_domain(domain: str, whatsmyip_domains: set) -> bool: """ Check if a domain is a known "what's my IP" service. diff --git a/greedybear/cronjobs/repositories/cowrie_session.py b/greedybear/cronjobs/repositories/cowrie_session.py index 313c67b5..6fb14029 100644 --- a/greedybear/cronjobs/repositories/cowrie_session.py +++ b/greedybear/cronjobs/repositories/cowrie_session.py @@ -155,7 +155,13 @@ def delete_sessions_without_commands(self, cutoff_date) -> int: deleted_count, _ = CowrieSession.objects.filter(start_time__lte=cutoff_date, commands__isnull=True).delete() return deleted_count - def add_credential(self, session: CowrieSession, username: str, password: str) -> None: + def add_credential( + self, + session: CowrieSession, + username: str, + password: str, + protocol: str | None = None, + ) -> None: """ Get or create a Credential and associate it with the session. @@ -163,8 +169,14 @@ def add_credential(self, session: CowrieSession, username: str, password: str) - session: The CowrieSession instance to associate the credential with. username: The credential username. password: The credential password. + protocol: Optional protocol associated with the credential. """ from greedybear.models import Credential - credential, _ = Credential.objects.get_or_create(username=username, password=password) + normalized_protocol = protocol or "" + credential, _ = Credential.objects.get_or_create( + username=username, + password=password, + protocol=normalized_protocol, + ) session.credentials.add(credential) diff --git a/greedybear/migrations/0045_credential_protocol.py b/greedybear/migrations/0045_credential_protocol.py new file mode 100644 index 00000000..fa84b609 --- /dev/null +++ b/greedybear/migrations/0045_credential_protocol.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.12 on 2026-03-18 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("greedybear", "0044_cowriefiletransfer"), + ] + + operations = [ + migrations.AddField( + model_name="credential", + name="protocol", + field=models.CharField(blank=True, default="", max_length=32), + ), + migrations.RemoveConstraint( + model_name="credential", + name="unique_credential", + ), + migrations.AddConstraint( + model_name="credential", + constraint=models.UniqueConstraint( + fields=("username", "password", "protocol"), + name="unique_credential", + ), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 0caf3259..c7a98d8f 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -129,16 +129,23 @@ def __str__(self): class Credential(models.Model): username = models.CharField(max_length=256, blank=False) password = models.CharField(max_length=256, blank=False) + protocol = models.CharField(max_length=32, blank=True, default="") class Meta: - constraints = [models.UniqueConstraint(fields=["username", "password"], name="unique_credential")] + constraints = [ + models.UniqueConstraint( + fields=["username", "password", "protocol"], + name="unique_credential", + ) + ] indexes = [ models.Index(fields=["username"]), models.Index(fields=["password"]), ] def __str__(self): - return f"{self.username} | {self.password}" + protocol_part = f" | {self.protocol}" if self.protocol else "" + return f"{self.username} | {self.password}{protocol_part}" class CowrieSession(models.Model): diff --git a/tests/__init__.py b/tests/__init__.py index 053d2fac..674d8254 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -169,7 +169,7 @@ def setUpTestData(cls): source=cls.ioc, commands=cls.command_sequence, ) - credential, _ = Credential.objects.get_or_create(username="root", password="root") + credential, _ = Credential.objects.get_or_create(username="root", password="root", protocol="") cls.cowrie_session.credentials.add(credential) cls.cowrie_session.save() @@ -193,7 +193,7 @@ def setUpTestData(cls): source=cls.ioc_2, commands=cls.command_sequence_2, ) - credential_2, _ = Credential.objects.get_or_create(username="user", password="user") + credential_2, _ = Credential.objects.get_or_create(username="user", password="user", protocol="") cls.cowrie_session_2.credentials.add(credential_2) cls.cowrie_session_2.save() diff --git a/tests/test_cowrie_session_repository.py b/tests/test_cowrie_session_repository.py index fe86083a..4f10b3dd 100644 --- a/tests/test_cowrie_session_repository.py +++ b/tests/test_cowrie_session_repository.py @@ -3,7 +3,7 @@ from django.db import IntegrityError from greedybear.cronjobs.repositories import CowrieSessionRepository -from greedybear.models import IOC, CommandSequence, CowrieFileTransfer, CowrieSession +from greedybear.models import IOC, CommandSequence, CowrieFileTransfer, CowrieSession, Credential from . import CustomTestCase @@ -146,6 +146,30 @@ def test_get_or_create_file_transfer_updates_timestamp(self): 1, ) + def test_add_credential_uses_default_protocol_variant(self): + session = self.cowrie_session + + self.repo.add_credential(session, username="root", password="root") + + credential = Credential.objects.get(username="root", password="root", protocol="") + self.assertTrue(session.credentials.filter(pk=credential.pk).exists()) + + def test_add_credential_protocol_none_maps_to_default_protocol(self): + session = self.cowrie_session + + self.repo.add_credential(session, username="admin", password="admin", protocol=None) + + self.assertTrue(Credential.objects.filter(username="admin", password="admin", protocol="").exists()) + + def test_add_credential_does_not_raise_with_protocol_variants_present(self): + session = self.cowrie_session + Credential.objects.get_or_create(username="root", password="root", protocol="ssh") + + self.repo.add_credential(session, username="root", password="root") + + default_credential = Credential.objects.get(username="root", password="root", protocol="") + self.assertTrue(session.credentials.filter(pk=default_credential.pk).exists()) + class TestCowrieSessionRepositoryCleanup(CustomTestCase): """Tests for cleanup-related methods in CowrieSessionRepository.""" diff --git a/tests/test_heralding_strategy.py b/tests/test_heralding_strategy.py new file mode 100644 index 00000000..cf6a6707 --- /dev/null +++ b/tests/test_heralding_strategy.py @@ -0,0 +1,387 @@ +""" +Tests for the Heralding credential-catching honeypot extraction strategy. +""" + +from unittest.mock import Mock, patch + +from greedybear.consts import SCANNER +from greedybear.cronjobs.extraction.strategies.heralding import ( + HERALDING_HONEYPOT, + HERALDING_PROTOCOLS, + HeraldingExtractionStrategy, + normalize_credential_field, +) + +from . import ExtractionTestCase + + +class TestHeraldingExtractionStrategy(ExtractionTestCase): + """Tests for the main extract_from_hits entrypoint.""" + + def setUp(self): + super().setUp() + self.strategy = HeraldingExtractionStrategy( + honeypot="Heralding", + ioc_repo=self.mock_ioc_repo, + sensor_repo=self.mock_sensor_repo, + ) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + @patch("greedybear.cronjobs.extraction.strategies.heralding.threatfox_submission") + def test_extract_scanner_ips(self, mock_threatfox, mock_credential_objects, mock_iocs_from_hits): + """Scanner IPs are extracted as SCANNER-type IOCs linked to Heralding.""" + mock_credential_objects.get_or_create.return_value = (Mock(), True) + mock_ioc = self._create_mock_ioc("1.2.3.4") + mock_iocs_from_hits.return_value = [mock_ioc] + self.strategy.ioc_processor.add_ioc = Mock(return_value=mock_ioc) + + hits = [{"src_ip": "1.2.3.4", "dest_port": 22, "protocol": "ssh", "@timestamp": "2025-01-01T00:00:00"}] + self.strategy.extract_from_hits(hits) + + mock_iocs_from_hits.assert_called_once_with(hits) + self.strategy.ioc_processor.add_ioc.assert_any_call( + mock_ioc, + attack_type=SCANNER, + general_honeypot_name=HERALDING_HONEYPOT, + ) + self.assertEqual(len(self.strategy.ioc_records), 1) + mock_threatfox.assert_called_once() + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_none_ioc_record_skipped(self, mock_credential_objects, mock_iocs_from_hits): + """IOC records that resolve to None are silently skipped.""" + mock_ioc = self._create_mock_ioc() + mock_iocs_from_hits.return_value = [mock_ioc] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + self.strategy.ioc_processor.add_ioc = Mock(return_value=None) + + hits = [{"src_ip": "1.2.3.4", "dest_port": 22, "@timestamp": "2025-01-01T00:00:00"}] + self.strategy.extract_from_hits(hits) + + self.assertEqual(len(self.strategy.ioc_records), 0) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + @patch("greedybear.cronjobs.extraction.strategies.heralding.threatfox_submission") + def test_multiple_scanners(self, mock_threatfox, mock_credential_objects, mock_iocs_from_hits): + """Multiple scanner IPs from the same batch are all processed.""" + mock_credential_objects.get_or_create.return_value = (Mock(), True) + ioc1 = self._create_mock_ioc("1.2.3.4") + ioc2 = self._create_mock_ioc("5.6.7.8") + mock_iocs_from_hits.return_value = [ioc1, ioc2] + self.strategy.ioc_processor.add_ioc = Mock(side_effect=[ioc1, ioc2]) + + hits = [ + {"src_ip": "1.2.3.4", "dest_port": 22, "protocol": "ssh", "@timestamp": "2025-01-01T00:00:00"}, + {"src_ip": "5.6.7.8", "dest_port": 21, "protocol": "ftp", "@timestamp": "2025-01-01T00:00:00"}, + ] + self.strategy.extract_from_hits(hits) + + self.assertEqual(len(self.strategy.ioc_records), 2) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_extract_from_hits_calls_both_phases(self, mock_credential_objects, mock_iocs_from_hits): + """extract_from_hits runs both scanner extraction and credential classification.""" + mock_ioc = self._create_mock_ioc("1.2.3.4") + mock_iocs_from_hits.return_value = [mock_ioc] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + self.strategy.ioc_processor.add_ioc = Mock(return_value=mock_ioc) + + hits = [{"src_ip": "1.2.3.4", "dest_port": 22, "protocol": "ssh", "@timestamp": "2025-01-01T00:00:00"}] + + with ( + patch.object(self.strategy, "_get_scanners", wraps=self.strategy._get_scanners) as spy_scanners, + patch.object(self.strategy, "_classify_credential_attacks", wraps=self.strategy._classify_credential_attacks) as spy_classify, + ): + self.strategy.extract_from_hits(hits) + + spy_scanners.assert_called_once_with(hits) + spy_classify.assert_called_once_with(hits) + + +class TestHeraldingProtocolExtraction(ExtractionTestCase): + """Tests for _extract_protocol helper.""" + + def setUp(self): + super().setUp() + self.strategy = HeraldingExtractionStrategy( + honeypot="Heralding", + ioc_repo=self.mock_ioc_repo, + sensor_repo=self.mock_sensor_repo, + ) + + def test_known_protocol_returned(self): + for proto in ["ssh", "ftp", "telnet", "http", "https", "pop3", "imap", "smtp", "vnc", "rdp", "socks5"]: + with self.subTest(protocol=proto): + hit = {"src_ip": "1.2.3.4", "protocol": proto} + result = self.strategy._extract_protocol(hit) + self.assertEqual(result, proto) + + def test_protocol_normalised_to_lowercase(self): + hit = {"src_ip": "1.2.3.4", "protocol": "SSH"} + result = self.strategy._extract_protocol(hit) + self.assertEqual(result, "ssh") + + def test_protocol_with_whitespace_stripped(self): + hit = {"src_ip": "1.2.3.4", "protocol": " ftp "} + result = self.strategy._extract_protocol(hit) + self.assertEqual(result, "ftp") + + def test_missing_protocol_field_returns_none(self): + hit = {"src_ip": "1.2.3.4"} + result = self.strategy._extract_protocol(hit) + self.assertIsNone(result) + + def test_empty_protocol_field_returns_none(self): + hit = {"src_ip": "1.2.3.4", "protocol": ""} + result = self.strategy._extract_protocol(hit) + self.assertIsNone(result) + + def test_unknown_protocol_returns_none(self): + hit = {"src_ip": "1.2.3.4", "protocol": "unknown_proto"} + result = self.strategy._extract_protocol(hit) + self.assertIsNone(result) + + +class TestHeraldingCredentialClassification(ExtractionTestCase): + """Tests for _classify_credential_attacks and credential persistence logic.""" + + def setUp(self): + super().setUp() + self.strategy = HeraldingExtractionStrategy( + honeypot="Heralding", + ioc_repo=self.mock_ioc_repo, + sensor_repo=self.mock_sensor_repo, + ) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_protocol_persisted_on_credential(self, mock_credential_objects, mock_iocs_from_hits): + """A valid protocol and credential pair is persisted on Credential.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + hits = [{"src_ip": "1.2.3.4", "protocol": "ssh", "username": "root", "password": "toor"}] + self.strategy.extract_from_hits(hits) + + mock_credential_objects.get_or_create.assert_called_once_with( + username="root", + password="toor", + protocol="ssh", + ) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_multiple_protocols_all_stored(self, mock_credential_objects, mock_iocs_from_hits): + """Credential tuples for multiple protocols are all stored.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + hits = [ + {"src_ip": "1.2.3.4", "protocol": "ssh", "username": "u1", "password": "p1"}, + {"src_ip": "1.2.3.4", "protocol": "ftp", "username": "u2", "password": "p2"}, + {"src_ip": "1.2.3.4", "protocol": "telnet", "username": "u3", "password": "p3"}, + ] + self.strategy.extract_from_hits(hits) + + stored_protocols = {call[1]["protocol"] for call in mock_credential_objects.get_or_create.call_args_list} + self.assertEqual(stored_protocols, {"ssh", "ftp", "telnet"}) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_duplicate_credential_protocol_deduplicated(self, mock_credential_objects, mock_iocs_from_hits): + """Repeated hits for same username/password/protocol are deduplicated per batch.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + hits = [ + {"src_ip": "1.2.3.4", "protocol": "ssh", "username": "root", "password": "toor"}, + {"src_ip": "1.2.3.4", "protocol": "ssh", "username": "root", "password": "toor"}, + {"src_ip": "1.2.3.4", "protocol": "ssh", "username": "root", "password": "toor"}, + ] + self.strategy.extract_from_hits(hits) + + ssh_calls = [c for c in mock_credential_objects.get_or_create.call_args_list if c[1]["protocol"] == "ssh"] + self.assertEqual(len(ssh_calls), 1) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_missing_credentials_skipped(self, mock_credential_objects, mock_iocs_from_hits): + """Hits with protocol but no credentials are ignored in classification.""" + mock_iocs_from_hits.return_value = [] + hits = [{"src_ip": "1.2.3.4", "protocol": "ssh"}] + self.strategy.extract_from_hits(hits) + + mock_credential_objects.get_or_create.assert_not_called() + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_unknown_protocol_not_stored(self, mock_credential_objects, mock_iocs_from_hits): + """Hits with an unknown protocol value do not produce credentials.""" + mock_iocs_from_hits.return_value = [] + + hits = [{"src_ip": "1.2.3.4", "protocol": "bogus_protocol", "username": "root", "password": "root"}] + self.strategy.extract_from_hits(hits) + + mock_credential_objects.get_or_create.assert_not_called() + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_missing_password_stores_empty_string(self, mock_credential_objects, mock_iocs_from_hits): + """Missing password is normalized to empty string for persistence.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + hits = [{"src_ip": "9.9.9.9", "protocol": "ssh", "username": "root"}] + self.strategy.extract_from_hits(hits) + + mock_credential_objects.get_or_create.assert_called_once_with( + username="root", + password="", + protocol="ssh", + ) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_new_credential_increments_counter(self, mock_credential_objects, mock_iocs_from_hits): + """Creating a new credential increments credentials_added.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + hits = [{"src_ip": "1.2.3.4", "protocol": "ftp", "username": "root", "password": "root"}] + self.strategy.extract_from_hits(hits) + + self.assertGreater(self.strategy.credentials_added, 0) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_existing_credential_not_counted(self, mock_credential_objects, mock_iocs_from_hits): + """Existing credentials (get_or_create returns created=False) do not increment counter.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), False) + + hits = [{"src_ip": "1.2.3.4", "protocol": "ssh", "username": "root", "password": "root"}] + self.strategy.extract_from_hits(hits) + + self.assertEqual(self.strategy.credentials_added, 0) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_two_credentials_same_protocol_both_stored(self, mock_credential_objects, mock_iocs_from_hits): + """Different username/password tuples are both stored for the same protocol.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + hits = [ + {"src_ip": "1.2.3.4", "protocol": "ssh", "username": "root", "password": "toor"}, + {"src_ip": "5.6.7.8", "protocol": "ssh", "username": "admin", "password": "admin"}, + ] + self.strategy.extract_from_hits(hits) + + stored_users = {call[1]["username"] for call in mock_credential_objects.get_or_create.call_args_list} + self.assertEqual(stored_users, {"root", "admin"}) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_hits_without_protocol_produce_no_credentials(self, mock_credential_objects, mock_iocs_from_hits): + """Hits without protocol are ignored during credential classification.""" + mock_iocs_from_hits.return_value = [] + + hits = [{"src_ip": "1.2.3.4", "dest_port": 22, "username": "root", "password": "root"}] + self.strategy.extract_from_hits(hits) + + mock_credential_objects.get_or_create.assert_not_called() + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_null_byte_credentials_are_normalized(self, mock_credential_objects, mock_iocs_from_hits): + """NUL bytes in credentials are replaced before persistence.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + hits = [{"src_ip": "1.2.3.4", "protocol": "ssh", "username": "ro\x00ot", "password": "pa\x00ss"}] + self.strategy.extract_from_hits(hits) + + mock_credential_objects.get_or_create.assert_called_once_with( + username="ro[NUL]ot", + password="pa[NUL]ss", + protocol="ssh", + ) + + @patch("greedybear.cronjobs.extraction.strategies.heralding.iocs_from_hits") + @patch("greedybear.cronjobs.extraction.strategies.heralding.Credential.objects") + def test_credential_fields_are_truncated_to_model_length(self, mock_credential_objects, mock_iocs_from_hits): + """Credential fields longer than model max_length are truncated.""" + mock_iocs_from_hits.return_value = [] + mock_credential_objects.get_or_create.return_value = (Mock(), True) + + long_username = "u" * 400 + long_password = "p" * 500 + hits = [{"src_ip": "1.2.3.4", "protocol": "ssh", "username": long_username, "password": long_password}] + self.strategy.extract_from_hits(hits) + + mock_credential_objects.get_or_create.assert_called_once_with( + username="u" * 256, + password="p" * 256, + protocol="ssh", + ) + + +class TestHeraldingCredentialNormalization(ExtractionTestCase): + """Tests for normalize_credential_field helper.""" + + def test_none_becomes_empty_string(self): + self.assertEqual(normalize_credential_field(None), "") + + def test_truncates_to_max_length(self): + self.assertEqual(normalize_credential_field("x" * 300), "x" * 256) + + +class TestHeraldingProtocolSet(ExtractionTestCase): + """Validate the HERALDING_PROTOCOLS set for completeness and correctness.""" + + def test_common_protocols_present(self): + expected = {"ssh", "telnet", "ftp", "http", "https", "pop3", "imap", "smtp", "vnc", "rdp", "socks5"} + for proto in expected: + self.assertIn(proto, HERALDING_PROTOCOLS, f"Protocol {proto!r} missing from HERALDING_PROTOCOLS") + + def test_protocols_all_lowercase(self): + for proto in HERALDING_PROTOCOLS: + self.assertEqual(proto, proto.lower(), f"Protocol {proto!r} is not lowercase") + + def test_is_frozenset(self): + self.assertIsInstance(HERALDING_PROTOCOLS, frozenset) + + def test_database_protocols_present(self): + db_protocols = {"postgresql", "mysql", "mssql"} + for proto in db_protocols: + self.assertIn(proto, HERALDING_PROTOCOLS, f"DB protocol {proto!r} missing from HERALDING_PROTOCOLS") + + +class TestHeraldingFactoryIntegration(ExtractionTestCase): + """Integration test: factory produces a HeraldingExtractionStrategy for 'Heralding'.""" + + def test_factory_returns_heralding_strategy(self): + from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory + + factory = ExtractionStrategyFactory( + ioc_repo=self.mock_ioc_repo, + sensor_repo=self.mock_sensor_repo, + ) + strategy = factory.get_strategy("Heralding") + self.assertIsInstance(strategy, HeraldingExtractionStrategy) + + def test_factory_returns_generic_for_unknown(self): + from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory + from greedybear.cronjobs.extraction.strategies.generic import GenericExtractionStrategy + + factory = ExtractionStrategyFactory( + ioc_repo=self.mock_ioc_repo, + sensor_repo=self.mock_sensor_repo, + ) + strategy = factory.get_strategy("UnknownHoneypot") + self.assertIsInstance(strategy, GenericExtractionStrategy) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 645d20b8..d8112cbf 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1,3 +1,4 @@ +from django.db import IntegrityError from django.test import tag from . import MigrationTestCase @@ -217,3 +218,29 @@ def test_credentials_migrated_to_credential_model(self): session = CowrieSession.objects.get(session_id=1) self.assertEqual(session.credentials.count(), 2) + + +@tag("migration") +class TestCredentialProtocolMigration(MigrationTestCase): + """Tests migration adding protocol support to Credential uniqueness.""" + + migrate_from = "0044_cowriefiletransfer" + migrate_to = "0045_credential_protocol" + + def test_default_protocol_set_and_uniqueness_includes_protocol(self): + credential_old = self.old_state.apps.get_model(self.app_name, "Credential") + + legacy = credential_old.objects.create(username="root", password="root") + + new_state = self.apply_tested_migration() + Credential = new_state.apps.get_model(self.app_name, "Credential") + + migrated = Credential.objects.get(pk=legacy.pk) + self.assertEqual(migrated.protocol, "") + + with self.assertRaises(IntegrityError): + Credential.objects.create(username="root", password="root", protocol="") + + Credential.objects.create(username="root", password="root", protocol="ssh") + Credential.objects.create(username="root", password="root", protocol="ftp") + self.assertEqual(Credential.objects.filter(username="root", password="root").count(), 3) diff --git a/tests/test_models.py b/tests/test_models.py index 99811c45..cc8f9f07 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,7 @@ +from django.db import IntegrityError + from greedybear.enums import IpReputation -from greedybear.models import IocType, Statistics, Tag, ViewType +from greedybear.models import Credential, IocType, Statistics, Tag, ViewType from . import CustomTestCase @@ -89,3 +91,16 @@ def test_tag_cascade_delete(self): temp_ioc.delete() self.assertEqual(Tag.objects.filter(ioc_id=temp_ioc.id).count(), 0) + + def test_credential_unique_constraint_with_default_protocol(self): + Credential.objects.create(username="dup_user", password="dup_pass", protocol="") + + with self.assertRaises(IntegrityError): + Credential.objects.create(username="dup_user", password="dup_pass", protocol="") + + def test_credential_unique_constraint_allows_same_pair_across_protocols(self): + Credential.objects.create(username="proto_user", password="proto_pass", protocol="") + Credential.objects.create(username="proto_user", password="proto_pass", protocol="ssh") + Credential.objects.create(username="proto_user", password="proto_pass", protocol="ftp") + + self.assertEqual(Credential.objects.filter(username="proto_user", password="proto_pass").count(), 3) From 6ec9ae7049d8e060072b16eaa67fdd8170077173 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:16:16 +0100 Subject: [PATCH 074/109] build(deps): bump sass from 1.97.3 to 1.98.0 in /frontend (#1078) Bumps [sass](https://github.com/sass/dart-sass) from 1.97.3 to 1.98.0. - [Release notes](https://github.com/sass/dart-sass/releases) - [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md) - [Commits](https://github.com/sass/dart-sass/compare/1.97.3...1.98.0) --- updated-dependencies: - dependency-name: sass dependency-version: 1.98.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> --- frontend/package-lock.json | 18 +++++++++--------- frontend/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b34e77fb..689c94b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "react-use": "^17.6.0", "reactstrap": "^9.2.3", "recharts": "^2.15.4", - "sass": "^1.97.3", + "sass": "^1.98.0", "zustand": "^4.5.7" }, "devDependencies": { @@ -7802,12 +7802,12 @@ } }, "node_modules/sass": { - "version": "1.97.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", - "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", "dependencies": { "chokidar": "^4.0.0", - "immutable": "^5.0.2", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -14812,13 +14812,13 @@ } }, "sass": { - "version": "1.97.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", - "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", "requires": { "@parcel/watcher": "^2.4.1", "chokidar": "^4.0.0", - "immutable": "^5.0.2", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" } }, diff --git a/frontend/package.json b/frontend/package.json index 42f2e6bb..224b9f21 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "react-use": "^17.6.0", "reactstrap": "^9.2.3", "recharts": "^2.15.4", - "sass": "^1.97.3", + "sass": "^1.98.0", "zustand": "^4.5.7" }, "scripts": { From 6a79346abd01563cfdef92dbe4bb732ef8cc7461 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:07:14 +0100 Subject: [PATCH 075/109] build(deps-dev): bump @vitest/coverage-v8 in /frontend (#1079) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.0.18 to 4.1.0. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 369 +++++++++++++++++++------------------ frontend/package.json | 2 +- 2 files changed, 186 insertions(+), 185 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 689c94b6..325bae89 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^5.1.4", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -2238,8 +2238,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@testing-library/dom": { "version": "8.16.0", @@ -2372,7 +2371,6 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "license": "MIT", "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -2436,8 +2434,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/estree": { "version": "1.0.8", @@ -2533,29 +2530,28 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", "dev": true, - "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2564,29 +2560,53 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, - "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, - "license": "MIT", "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -2595,13 +2615,12 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -2609,13 +2628,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2624,29 +2643,34 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, - "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/@xobotyi/scrollbar-width": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", @@ -2905,7 +2929,6 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" } @@ -2917,11 +2940,10 @@ "dev": true }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", @@ -2932,8 +2954,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/astral-regex": { "version": "2.0.0", @@ -3225,7 +3246,6 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" } @@ -4089,11 +4109,10 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -4694,7 +4713,6 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -6357,7 +6375,6 @@ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -6889,8 +6906,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/picocolors": { "version": "1.1.1", @@ -8142,11 +8158,10 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", @@ -9237,31 +9252,30 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -9277,12 +9291,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -9311,33 +9326,9 @@ }, "jsdom": { "optional": true - } - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true }, "vite": { - "optional": true + "optional": false } } }, @@ -11065,81 +11056,102 @@ } }, "@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", "dev": true, "requires": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.0.3" } }, "@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "requires": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, + "@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "requires": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + } + }, "@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "requires": { "tinyrainbow": "^3.0.3" } }, "@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "requires": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "requires": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true }, "@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "requires": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" + }, + "dependencies": { + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + } } }, "@xobotyi/scrollbar-width": { @@ -11333,9 +11345,9 @@ "dev": true }, "ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, "requires": { "@jridgewell/trace-mapping": "^0.3.31", @@ -12201,9 +12213,9 @@ } }, "es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true }, "es-object-atoms": { @@ -15048,9 +15060,9 @@ } }, "std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true }, "stop-iteration-iterator": { @@ -15768,44 +15780,33 @@ } }, "vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, - "requires": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "requires": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "dependencies": { - "@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, - "requires": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - } - }, "picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 224b9f21..9a47b805 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^5.1.4", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", From eb916ea6eb6c5ad12861a77241c943d8f6908f08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:10:48 +0100 Subject: [PATCH 076/109] build(deps-dev): bump jsdom from 28.1.0 to 29.0.0 in /frontend (#1080) Bumps [jsdom](https://github.com/jsdom/jsdom) from 28.1.0 to 29.0.0. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Changelog](https://github.com/jsdom/jsdom/blob/v29.0.0/Changelog.md) - [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v29.0.0) --- updated-dependencies: - dependency-name: jsdom dependency-version: 29.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 584 ++++++++++++++++--------------------- frontend/package.json | 2 +- 2 files changed, 247 insertions(+), 339 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 325bae89..49c6e898 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,20 +38,13 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^4.6.0", - "jsdom": "^28.1.0", + "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.4.0", "vite": "^7.3.1", "vitest": "^4.0.18" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", @@ -59,108 +52,79 @@ "dev": true }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", "dev": true, - "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", - "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.1", - "@csstools/css-calc": "^3.0.0" + "lru-cache": "^11.2.6" }, "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, - "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", "dev": true, - "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, - "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, - "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@babel/code-frame": { "version": "7.29.0", @@ -591,9 +555,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", - "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -605,7 +569,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=20.19.0" } @@ -634,6 +597,33 @@ "@csstools/css-tokenizer": "^4.0.0" } }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", @@ -1401,11 +1391,10 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", - "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, - "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -2699,16 +2688,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -3073,7 +3052,6 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, - "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" } @@ -3438,53 +3416,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", - "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.2", - "@csstools/css-syntax-patches-for-csstree": "^1.0.26", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/cssstyle/node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5445,34 +5376,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", @@ -5870,8 +5773,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/is-regex": { "version": "1.2.1", @@ -6101,36 +6003,35 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", "dev": true, - "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -6141,12 +6042,63 @@ } } }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, "node_modules/jsdom/node_modules/tr46": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, - "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, @@ -6155,11 +6107,10 @@ } }, "node_modules/jsdom/node_modules/whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, - "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", @@ -8764,24 +8715,22 @@ } }, "node_modules/tldts": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", - "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", "dev": true, - "license": "MIT", "dependencies": { - "tldts-core": "^7.0.23" + "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", - "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", - "dev": true, - "license": "MIT" + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -8815,11 +8764,10 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" }, @@ -8991,11 +8939,10 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=20.18.1" } @@ -9601,12 +9548,6 @@ } }, "dependencies": { - "@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true - }, "@adobe/css-tools": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", @@ -9614,69 +9555,59 @@ "dev": true }, "@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", "dev": true, "requires": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" + "lru-cache": "^11.2.6" }, "dependencies": { - "@csstools/css-color-parser": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", - "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", - "dev": true, - "requires": { - "@csstools/color-helpers": "^6.0.1", - "@csstools/css-calc": "^3.0.0" - } - }, "lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true } } }, "@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", "dev": true, "requires": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "lru-cache": "^11.2.7" }, "dependencies": { "css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "requires": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" } }, "lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true }, "mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true } } @@ -9984,9 +9915,9 @@ } }, "@csstools/color-helpers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", - "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true }, "@csstools/css-calc": { @@ -9996,6 +9927,16 @@ "dev": true, "requires": {} }, + "@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "requires": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + } + }, "@csstools/css-parser-algorithms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", @@ -10377,9 +10318,9 @@ } }, "@exodus/bytes": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", - "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, "requires": {} }, @@ -11174,12 +11115,6 @@ "peer": true, "requires": {} }, - "agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true - }, "ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -11698,42 +11633,6 @@ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true }, - "cssstyle": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", - "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", - "dev": true, - "requires": { - "@asamuzakjp/css-color": "^4.1.2", - "@csstools/css-syntax-patches-for-csstree": "^1.0.26", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.5" - }, - "dependencies": { - "css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "requires": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - } - }, - "lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true - }, - "mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true - } - } - }, "csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -13176,26 +13075,6 @@ "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", "dev": true }, - "http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "requires": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - } - }, - "https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "requires": { - "agent-base": "^7.1.2", - "debug": "4" - } - }, "hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", @@ -13622,34 +13501,63 @@ } }, "jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", "dev": true, "requires": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "dependencies": { + "@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "requires": {} + }, + "css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "requires": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + } + }, + "lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true + }, + "mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, "tr46": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", @@ -13660,9 +13568,9 @@ } }, "whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "requires": { "@exodus/bytes": "^1.11.0", @@ -15485,18 +15393,18 @@ "dev": true }, "tldts": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", - "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", "dev": true, "requires": { - "tldts-core": "^7.0.23" + "tldts-core": "^7.0.27" } }, "tldts-core": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", - "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", "dev": true }, "to-regex-range": { @@ -15522,9 +15430,9 @@ } }, "tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "requires": { "tldts": "^7.0.5" @@ -15649,9 +15557,9 @@ } }, "undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", "dev": true }, "unicorn-magic": { diff --git a/frontend/package.json b/frontend/package.json index 9a47b805..956d92e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,7 +57,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^4.6.0", - "jsdom": "^28.1.0", + "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.4.0", "vite": "^7.3.1", From 02c07a6be9c30494b6f8beff2e115d88ecd46038 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:16:04 +0100 Subject: [PATCH 077/109] build(deps-dev): bump @vitejs/plugin-react in /frontend (#1081) Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.4 to 5.2.0. - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/plugin-react@5.2.0/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.2.0/packages/plugin-react) --- updated-dependencies: - dependency-name: "@vitejs/plugin-react" dependency-version: 5.2.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 17 ++++++++--------- frontend/package.json | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 49c6e898..491ff293 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "@vitest/coverage-v8": "^4.1.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", @@ -2498,11 +2498,10 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", @@ -2515,7 +2514,7 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@vitest/coverage-v8": { @@ -10983,9 +10982,9 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "requires": { "@babel/core": "^7.29.0", diff --git a/frontend/package.json b/frontend/package.json index 956d92e3..68467421 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,7 +49,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^5.2.0", "@vitest/coverage-v8": "^4.1.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", From 14284eaa02395b99b4eacdc3d2ee3f4506811cc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:07:07 +0100 Subject: [PATCH 078/109] build(deps-dev): bump vite from 7.3.1 to 8.0.0 in /frontend (#1082) * build(deps-dev): bump vite from 7.3.1 to 8.0.0 in /frontend Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 8.0.0. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.0/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * adapt vite config --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: tim <46972822+regulartim@users.noreply.github.com> --- frontend/package-lock.json | 1367 ++++++++++++++++++++++-------------- frontend/package.json | 2 +- frontend/vite.config.js | 13 +- 3 files changed, 850 insertions(+), 532 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 491ff293..495864ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,7 @@ "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.4.0", - "vite": "^7.3.1", + "vite": "^8.0.1", "vitest": "^4.0.18" } }, @@ -754,6 +754,37 @@ "postcss-selector-parser": "^7.1.1" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.9.2", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz", @@ -911,6 +942,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -928,6 +960,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -945,6 +978,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -962,6 +996,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -979,6 +1014,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -996,6 +1032,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1013,6 +1050,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1030,6 +1068,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1047,6 +1086,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1064,6 +1104,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1081,6 +1122,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1098,6 +1140,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1115,6 +1158,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1132,6 +1176,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1149,6 +1194,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1166,6 +1212,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1183,6 +1230,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1200,6 +1248,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1217,6 +1266,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1234,6 +1284,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1251,6 +1302,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1268,6 +1320,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -1285,6 +1338,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -1302,6 +1356,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1319,6 +1374,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1336,6 +1392,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1497,6 +1554,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1535,6 +1608,15 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -1847,362 +1929,252 @@ "node": ">=14.0.0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "cpu": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, - "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "cpu": [ - "ia32" + "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -2307,6 +2279,16 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", @@ -3827,7 +3809,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "optional": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -4107,6 +4089,8 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4955,7 +4939,6 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -6239,13 +6222,262 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "peer": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lines-and-columns": { @@ -6886,9 +7118,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -6904,7 +7136,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7632,50 +7863,44 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", "dev": true, - "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true }, "node_modules/rtl-css-js": { "version": "1.16.1", @@ -9092,17 +9317,15 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dev": true, - "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "bin": { @@ -9119,9 +9342,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -9134,13 +9358,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -9166,30 +9393,11 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -9976,6 +10184,37 @@ "dev": true, "requires": {} }, + "@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, "@emotion/babel-plugin": { "version": "11.9.2", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz", @@ -10102,182 +10341,208 @@ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/android-arm": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/android-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/android-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/darwin-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/darwin-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/freebsd-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/freebsd-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-arm": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-ia32": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-loong64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-mips64el": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-riscv64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-s390x": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/linux-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/netbsd-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/netbsd-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/openbsd-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/openbsd-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/openharmony-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/sunos-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/win32-arm64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/win32-ia32": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@esbuild/win32-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "@eslint/eslintrc": { "version": "1.3.0", @@ -10395,6 +10660,18 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "dev": true }, + "@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10421,6 +10698,12 @@ "fastq": "^1.6.0" } }, + "@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true + }, "@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -10542,186 +10825,119 @@ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==" }, - "@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true - }, - "@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "dev": true, - "optional": true - }, - "@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "dev": true, "optional": true }, - "@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "dev": true, "optional": true }, - "@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "dev": true, "optional": true }, - "@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "dev": true, "optional": true }, - "@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "dev": true, "optional": true }, - "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "dev": true, "optional": true }, - "@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "dev": true, "optional": true }, - "@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "dev": true, "optional": true }, - "@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "dev": true, "optional": true }, - "@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "dev": true, "optional": true }, - "@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "dev": true, "optional": true }, - "@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "dev": true, "optional": true }, - "@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "dev": true, - "optional": true + "optional": true, + "requires": { + "@napi-rs/wasm-runtime": "^1.1.1" + } }, - "@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "dev": true, "optional": true }, - "@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "dev": true, "optional": true }, - "@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "dev": true, - "optional": true + "@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true }, "@rtsao/scc": { "version": "1.1.0", @@ -10797,6 +11013,16 @@ "dev": true, "requires": {} }, + "@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, "@types/aria-query": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", @@ -11938,7 +12164,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "optional": true + "devOptional": true }, "doctrine": { "version": "3.0.0", @@ -12160,6 +12386,8 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, + "optional": true, + "peer": true, "requires": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", @@ -13684,6 +13912,103 @@ "type-check": "~0.4.0" } }, + "lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "requires": { + "detect-libc": "^2.0.3", + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "dev": true, + "optional": true + }, + "lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "dev": true, + "optional": true + }, + "lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "dev": true, + "optional": true + }, + "lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "dev": true, + "optional": true + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -14139,9 +14464,9 @@ "dev": true }, "postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "requires": { "nanoid": "^3.3.11", @@ -14639,39 +14964,37 @@ "glob": "^7.1.3" } }, - "rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "@types/estree": "1.0.8", - "fsevents": "~2.3.2" + "rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, + "requires": { + "@oxc-project/types": "=0.120.0", + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "dependencies": { + "@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true + } } }, "rtl-css-js": { @@ -15657,27 +15980,19 @@ } }, "vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dev": true, "requires": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", "fsevents": "~2.3.3", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "dependencies": { - "fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "requires": {} - }, "picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 68467421..dc7424f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,7 +60,7 @@ "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.4.0", - "vite": "^7.3.1", + "vite": "^8.0.1", "vitest": "^4.0.18" } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index aff84cf2..dc979565 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -36,14 +36,17 @@ export default defineConfig({ build: { outDir: 'build', sourcemap: false, + chunkSizeWarningLimit: 1024, rollupOptions: { output: { // Split large dependencies into separate chunks for better caching and smaller initial load - manualChunks: { - recharts: ['recharts'], - vendor: ['react', 'react-dom', 'react-router-dom'], - certego: ['@certego/certego-ui'], - reactstrap: ['reactstrap'], + codeSplitting: { + groups: [ + { name: 'recharts', test: /\/recharts/ }, + { name: 'vendor', test: /\/react(-dom|-router-dom)?\// }, + { name: 'certego', test: /\/@certego\/certego-ui/ }, + { name: 'reactstrap', test: /\/reactstrap/ }, + ], }, }, }, From 9974f538c1acd4b73a6af9d2bdbd478f036b1545 Mon Sep 17 00:00:00 2001 From: Swara Dalvi Date: Mon, 23 Mar 2026 02:18:28 +0530 Subject: [PATCH 079/109] Added test for useAuthStore component:Closes #987 (#1037) * Added test for useAuthStore component * preetier fix --- frontend/tests/stores/useAuthStore.test.jsx | 336 ++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 frontend/tests/stores/useAuthStore.test.jsx diff --git a/frontend/tests/stores/useAuthStore.test.jsx b/frontend/tests/stores/useAuthStore.test.jsx new file mode 100644 index 00000000..18f38746 --- /dev/null +++ b/frontend/tests/stores/useAuthStore.test.jsx @@ -0,0 +1,336 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import axios from "axios"; +import useAuthStore from "../../src/stores/useAuthStore"; +import { AUTHENTICATION_STATUSES } from "../../src/constants"; +import { + CHECK_AUTHENTICATION_URI, + CHANGE_PASSWORD_URI, + LOGIN_URI, + LOGOUT_URI, + USERACCESS_URI, +} from "../../src/constants/api"; +import { addToast } from "@certego/certego-ui"; + +vi.mock("axios"); +vi.mock("@certego/certego-ui", () => ({ + addToast: vi.fn(), +})); + +const INITIAL_USER = { + full_name: "", + first_name: "", + last_name: "", + email: "", +}; + +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}; + +describe("useAuthStore", () => { + beforeEach(() => { + useAuthStore.setState({ + user: INITIAL_USER, + isSuperuser: false, + isAuthenticated: AUTHENTICATION_STATUSES.FALSE, + }); + + vi.clearAllMocks(); + }); + + describe("Initial State", () => { + test("initial state is correct", () => { + const state = useAuthStore.getState(); + + expect(state.user).toEqual(INITIAL_USER); + + expect(state.isSuperuser).toBe(false); + + expect(state.isAuthenticated).toBe(AUTHENTICATION_STATUSES.FALSE); + }); + }); + + describe("loginUser", () => { + test("sets authentication PENDING while request is in flight", async () => { + const deferred = createDeferred(); + axios.post.mockReturnValue(deferred.promise); + + const { loginUser } = useAuthStore.getState().service; + const loginPromise = loginUser({ username: "test", password: "123" }); + + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.PENDING, + ); + + deferred.resolve({ data: {} }); + await expect(loginPromise).resolves.toEqual({ data: {} }); + }); + + test("sets authentication FALSE and emits error toast on failure", async () => { + const err = { parsedMsg: "bad credentials" }; + axios.post.mockRejectedValue(err); + + const { loginUser } = useAuthStore.getState().service; + + await expect( + loginUser({ username: "test", password: "123" }), + ).rejects.toEqual(err); + + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.FALSE, + ); + + expect(addToast).toHaveBeenCalledWith( + "Login failed!", + "bad credentials", + "danger", + true, + ); + }); + + test("sets authentication TRUE and emits success toast on success", async () => { + axios.post.mockResolvedValue({ data: {} }); + + const { loginUser } = useAuthStore.getState().service; + + await loginUser({ username: "test", password: "123" }); + + expect(axios.post).toHaveBeenCalledWith( + LOGIN_URI, + { username: "test", password: "123" }, + { + certegoUIenableProgressBar: false, + headers: { "Content-Type": "application/json" }, + }, + ); + + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.TRUE, + ); + + expect(addToast).toHaveBeenCalledWith( + "You've been logged in!", + null, + "success", + ); + }); + }); + + describe("logoutUser", () => { + test("sets authentication PENDING while logout request is in flight", async () => { + const deferred = createDeferred(); + axios.post.mockReturnValue(deferred.promise); + + const { logoutUser } = useAuthStore.getState().service; + const logoutPromise = logoutUser(); + + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.PENDING, + ); + + deferred.resolve({}); + await logoutPromise; + }); + + test("clears user and auth state with info toast on success", async () => { + axios.post.mockResolvedValue({}); + + useAuthStore.setState({ + isAuthenticated: AUTHENTICATION_STATUSES.TRUE, + user: { + full_name: "Test", + first_name: "Test", + last_name: "User", + email: "test@test.com", + }, + isSuperuser: true, + }); + + const { logoutUser } = useAuthStore.getState().service; + + await logoutUser(); + + const state = useAuthStore.getState(); + + expect(state.isAuthenticated).toBe(AUTHENTICATION_STATUSES.FALSE); + + expect(state.isSuperuser).toBe(false); + + expect(state.user).toEqual(INITIAL_USER); + + expect(axios.post).toHaveBeenCalledWith(LOGOUT_URI, null, { + certegoUIenableProgressBar: false, + }); + + expect(addToast).toHaveBeenCalledWith("Logged out!", null, "info"); + }); + + test("still clears state and emits info toast on failure", async () => { + axios.post.mockRejectedValue(new Error("network")); + + useAuthStore.setState({ + isAuthenticated: AUTHENTICATION_STATUSES.TRUE, + user: { + full_name: "Test", + first_name: "Test", + last_name: "User", + email: "test@test.com", + }, + isSuperuser: true, + }); + + const { logoutUser } = useAuthStore.getState().service; + + await expect(logoutUser()).resolves.toBeUndefined(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(AUTHENTICATION_STATUSES.FALSE); + expect(state.isSuperuser).toBe(false); + expect(state.user).toEqual(INITIAL_USER); + expect(addToast).toHaveBeenCalledWith("Logged out!", null, "info"); + }); + }); + + describe("fetchUserAccess", () => { + test("stores user data", async () => { + axios.get.mockResolvedValue({ + data: { + user: { + full_name: "user1", + first_name: "user", + last_name: "1", + email: "user1@test.com", + }, + }, + }); + + const { fetchUserAccess } = useAuthStore.getState().service; + + await fetchUserAccess(); + + expect(axios.get).toHaveBeenCalledWith(USERACCESS_URI, { + certegoUIenableProgressBar: false, + headers: { "Content-Type": "application/json" }, + }); + + expect(useAuthStore.getState().user.email).toBe("user1@test.com"); + }); + + test("emits error toast when user access fetch fails", async () => { + const err = { parsedMsg: "access fetch failed" }; + axios.get.mockRejectedValue(err); + + const { fetchUserAccess } = useAuthStore.getState().service; + + await fetchUserAccess(); + + expect(addToast).toHaveBeenCalledWith( + "Error fetching user access information!", + "access fetch failed", + "danger", + ); + expect(useAuthStore.getState().user).toEqual(INITIAL_USER); + }); + }); + + describe("checkAuthentication", () => { + test("sets auth TRUE and updates superuser", async () => { + axios.get.mockResolvedValue({ + data: { is_superuser: true }, + }); + + const { checkAuthentication } = useAuthStore.getState(); + + await checkAuthentication(); + + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.TRUE, + ); + + expect(useAuthStore.getState().isSuperuser).toBe(true); + }); + + test("sets auth FALSE when auth check fails while currently authenticated", async () => { + useAuthStore.setState({ + isAuthenticated: AUTHENTICATION_STATUSES.TRUE, + }); + axios.get.mockRejectedValue(new Error("auth check failed")); + + const { checkAuthentication } = useAuthStore.getState(); + + await checkAuthentication(); + + expect(axios.get).toHaveBeenCalledWith(CHECK_AUTHENTICATION_URI, { + headers: { "Content-Type": "application/json" }, + }); + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.FALSE, + ); + }); + + test("keeps auth FALSE when auth check fails while already unauthenticated", async () => { + useAuthStore.setState({ + isAuthenticated: AUTHENTICATION_STATUSES.FALSE, + }); + axios.get.mockRejectedValue(new Error("auth check failed")); + + const { checkAuthentication } = useAuthStore.getState(); + + await checkAuthentication(); + + expect(useAuthStore.getState().isAuthenticated).toBe( + AUTHENTICATION_STATUSES.FALSE, + ); + }); + }); + + describe("changePassword", () => { + test("resolves on success and emits success toast", async () => { + const response = { data: {} }; + axios.post.mockResolvedValue(response); + + const { changePassword } = useAuthStore.getState().service; + + await expect(changePassword({ old: "a", new: "b" })).resolves.toEqual( + response, + ); + + expect(axios.post).toHaveBeenCalledWith( + CHANGE_PASSWORD_URI, + { old: "a", new: "b" }, + { + headers: { "Content-Type": "application/json" }, + certegoUIenableProgressBar: false, + }, + ); + expect(addToast).toHaveBeenCalledWith( + "Password changed successfully!", + null, + "success", + ); + }); + + test("rejects on failure and emits error toast", async () => { + const err = { parsedMsg: "old password incorrect" }; + axios.post.mockRejectedValue(err); + + const { changePassword } = useAuthStore.getState().service; + + await expect(changePassword({ old: "a", new: "b" })).rejects.toEqual(err); + + expect(addToast).toHaveBeenCalledWith( + "Failed to change password!", + "old password incorrect", + "danger", + true, + ); + }); + }); +}); From 152ce0458227c7b36ee8560e0f73b355159f4eed Mon Sep 17 00:00:00 2001 From: IQRAZAM <139967916+IQRAZAM@users.noreply.github.com> Date: Mon, 23 Mar 2026 01:51:22 +0500 Subject: [PATCH 080/109] fix(cronjob): propagate exceptions in execute() to prevent downstream errors. Closes #1034 (#1048) * fix: propagate exceptions in cronjob.execute to prevent downstream errors * Update test to expect exception propagation in Cronjob.execute * fix: update test to expect exception after Cronjob.execute() change * files formatted * fix: correct WhatsMyIP test to properly assert HTTPErrorExplanation --- greedybear/cronjobs/base.py | 4 ++-- tests/greedybear/cronjobs/test_whatsmyip.py | 8 ++++---- tests/test_mass_scanners.py | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/greedybear/cronjobs/base.py b/greedybear/cronjobs/base.py index 8fe1abe9..ec9ee70f 100644 --- a/greedybear/cronjobs/base.py +++ b/greedybear/cronjobs/base.py @@ -17,9 +17,9 @@ def execute(self): try: self.log.info("Starting execution") self.run() + self.success = True except Exception as e: self.log.exception(e) - else: - self.success = True + raise # <--- this line ensures failures propagate finally: self.log.info("Finished execution") diff --git a/tests/greedybear/cronjobs/test_whatsmyip.py b/tests/greedybear/cronjobs/test_whatsmyip.py index 9a72ea92..a8cc5b15 100644 --- a/tests/greedybear/cronjobs/test_whatsmyip.py +++ b/tests/greedybear/cronjobs/test_whatsmyip.py @@ -174,13 +174,13 @@ def test_raises_on_missing_list_key(self, mock_get): self.assertEqual(WhatsMyIPDomain.objects.count(), 0) @patch("greedybear.cronjobs.whatsmyip.requests.get") - def test_execute_sets_success_false_on_http_error(self, mock_get): - """Test that base class execute() marks task as failed on HTTP error.""" + def test_execute_raises_on_http_error(self, mock_get): + """Test that base class execute() propagates HTTP errors""" mock_response = MagicMock() mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("500 Server Error") mock_get.return_value = mock_response cron = whatsmyip.WhatsMyIPCron() - cron.execute() - self.assertFalse(cron.success) + with self.assertRaises(requests.exceptions.HTTPError): + cron.execute() diff --git a/tests/test_mass_scanners.py b/tests/test_mass_scanners.py index 064ebb90..46485794 100644 --- a/tests/test_mass_scanners.py +++ b/tests/test_mass_scanners.py @@ -261,11 +261,11 @@ def test_raises_on_timeout(self): self.assertEqual(MassScanner.objects.count(), 0) def test_execute_sets_success_false_on_http_error(self): - """Test that base class execute() marks task as failed on HTTP error.""" + """Test that base class execute() propagates HTTPError.""" mock_response = Mock() mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("500 Server Error") with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: mock_get.return_value = mock_response - self.cron.execute() - - self.assertFalse(self.cron.success) + # Expect exception to be raised now + with self.assertRaises(requests.exceptions.HTTPError): + self.cron.execute() From d77a85c6a3b5b101bff8c49460cc3598f375eab1 Mon Sep 17 00:00:00 2001 From: armoredvortex <66690593+armoredvortex@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:29:26 +0530 Subject: [PATCH 081/109] Fix ordering regression. Closes #1093 (#1094) * add 'ordering' to allowed unauthenticated query parameters * add ordering tests for feeds API * run linter --- api/views/feeds.py | 1 + tests/api/views/test_feeds_view.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/api/views/feeds.py b/api/views/feeds.py index 5cc29be1..b4207468 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -35,6 +35,7 @@ "feed_type", "attack_type", "ioc_type", + "ordering", "include_mass_scanners", "include_tor_exit_nodes", "prioritize", diff --git a/tests/api/views/test_feeds_view.py b/tests/api/views/test_feeds_view.py index 8918c152..7188f4f2 100644 --- a/tests/api/views/test_feeds_view.py +++ b/tests/api/views/test_feeds_view.py @@ -159,3 +159,21 @@ def test_400_feeds_multi_type_with_invalid(self): def test_400_feeds_pagination(self): response = self.client.get("/api/feeds/?page_size=10&page=1&feed_type=all&attack_type=test&age=recent") self.assertEqual(response.status_code, 400) + + def test_200_feeds_pagination_ordering_value_desc(self): + response = self.client.get( + "/api/feeds/?page_size=50&page=1&feed_type=all&attack_type=all&prioritize=recent&ordering=-value&include_mass_scanners&include_tor_exit_nodes" + ) + self.assertEqual(response.status_code, 200) + + values = [ioc["value"] for ioc in response.json()["results"]["iocs"]] + self.assertEqual(values, sorted(values, reverse=True)) + + def test_200_feeds_pagination_ordering_value_asc(self): + response = self.client.get( + "/api/feeds/?page_size=50&page=1&feed_type=all&attack_type=all&prioritize=recent&ordering=value&include_mass_scanners&include_tor_exit_nodes" + ) + self.assertEqual(response.status_code, 200) + + values = [ioc["value"] for ioc in response.json()["results"]["iocs"]] + self.assertEqual(values, sorted(values)) From c8289ea08e6f033158ffa3093bd1dd87759823a2 Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:22:32 +0100 Subject: [PATCH 082/109] bump flatted form 3.3.3 to 3.4.2 in /frontend --- frontend/package-lock.json | 768 +------------------------------------ 1 file changed, 6 insertions(+), 762 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 495864ed..39eb35ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -929,474 +929,6 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint/eslintrc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz", @@ -4082,50 +3614,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4832,9 +4320,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -10336,214 +9824,6 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" }, - "@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "dev": true, - "optional": true, - "peer": true - }, - "@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "dev": true, - "optional": true, - "peer": true - }, "@eslint/eslintrc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz", @@ -12381,42 +11661,6 @@ "is-symbol": "^1.0.4" } }, - "esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -12953,9 +12197,9 @@ } }, "flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "flux": { From 4f93e8fab1b29dd01c530d3e1b989a02acc58494 Mon Sep 17 00:00:00 2001 From: Chaitanya Chute Date: Tue, 24 Mar 2026 00:01:26 +0530 Subject: [PATCH 083/109] Move stats recording after validation in command_sequence_view. Closes #1045 (#1105) --- api/views/command_sequence.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/views/command_sequence.py b/api/views/command_sequence.py index 9a76b550..1cac2c81 100644 --- a/api/views/command_sequence.py +++ b/api/views/command_sequence.py @@ -47,13 +47,14 @@ def command_sequence_view(request): observable = request.query_params.get("query") include_similar = request.query_params.get("include_similar") is not None logger.info(f"Command Sequence view requested by {request.user} for {observable}") - source_ip = str(request.META["REMOTE_ADDR"]) - request_source = Statistics(source=source_ip, view=ViewType.COMMAND_SEQUENCE_VIEW.value) - request_source.save() if not observable: return HttpResponseBadRequest("Missing required 'query' parameter") + source_ip = str(request.META["REMOTE_ADDR"]) + request_source = Statistics(source=source_ip, view=ViewType.COMMAND_SEQUENCE_VIEW.value) + request_source.save() + if is_ip_address(observable): sessions = CowrieSession.objects.filter(source__name=observable, start_time__isnull=False, commands__isnull=False) sequences = {s.commands for s in sessions} From b56811cc01ad088fdbdad509e152995abd38406c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:49:37 +0100 Subject: [PATCH 084/109] build(deps-dev): bump @vitest/coverage-v8 in /frontend (#1116) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.0 to 4.1.1. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.1/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.1 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> --- frontend/package-lock.json | 190 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 96 insertions(+), 96 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 39eb35ee..66a63633 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^5.2.0", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -2032,13 +2032,13 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", + "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==", "dev": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.1", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2052,8 +2052,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "4.1.1", + "vitest": "4.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2062,15 +2062,15 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", "dev": true, "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, @@ -2079,12 +2079,12 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", "dev": true, "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2093,7 +2093,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2105,9 +2105,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", "dev": true, "dependencies": { "tinyrainbow": "^3.0.3" @@ -2117,12 +2117,12 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", "dev": true, "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.1", "pathe": "^2.0.3" }, "funding": { @@ -2130,13 +2130,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", "dev": true, "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2145,21 +2145,21 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", "dev": true, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, @@ -8894,18 +8894,18 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", "dev": true, "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8917,7 +8917,7 @@ "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8933,13 +8933,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -10502,13 +10502,13 @@ } }, "@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", + "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==", "dev": true, "requires": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.1", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -10520,74 +10520,74 @@ } }, "@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", "dev": true, "requires": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", "dev": true, "requires": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" } }, "@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", "dev": true, "requires": { "tinyrainbow": "^3.0.3" } }, "@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", "dev": true, "requires": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", "dev": true, "requires": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", "dev": true }, "@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", "dev": true, "requires": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, @@ -15246,18 +15246,18 @@ } }, "vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", "dev": true, "requires": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -15269,7 +15269,7 @@ "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index dc7424f0..d49e5eaf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^5.2.0", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", From f1a763549b784f439474203a78be3830e2339378 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:49:52 +0100 Subject: [PATCH 085/109] build(deps-dev): bump vite from 8.0.1 to 8.0.2 in /frontend (#1114) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.1 to 8.0.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.2 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> --- frontend/package-lock.json | 306 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 154 insertions(+), 154 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 66a63633..e2381be5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,7 @@ "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.4.0", - "vite": "^8.0.1", + "vite": "^8.0.2", "vitest": "^4.0.18" } }, @@ -1141,9 +1141,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true, "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1462,9 +1462,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", "cpu": [ "arm64" ], @@ -1478,9 +1478,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", "cpu": [ "arm64" ], @@ -1494,9 +1494,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", "cpu": [ "x64" ], @@ -1510,9 +1510,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", "cpu": [ "x64" ], @@ -1526,9 +1526,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", "cpu": [ "arm" ], @@ -1542,9 +1542,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", "cpu": [ "arm64" ], @@ -1558,9 +1558,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", "cpu": [ "arm64" ], @@ -1574,9 +1574,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", "cpu": [ "ppc64" ], @@ -1590,9 +1590,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", "cpu": [ "s390x" ], @@ -1606,9 +1606,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", "cpu": [ "x64" ], @@ -1622,9 +1622,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", "cpu": [ "x64" ], @@ -1638,9 +1638,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", "cpu": [ "arm64" ], @@ -1654,9 +1654,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", "cpu": [ "wasm32" ], @@ -1670,9 +1670,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", "cpu": [ "arm64" ], @@ -1686,9 +1686,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", "cpu": [ "x64" ], @@ -7352,13 +7352,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", "dev": true, "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7367,27 +7367,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", "dev": true }, "node_modules/rtl-css-js": { @@ -8805,15 +8805,15 @@ } }, "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", + "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", + "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "bin": { @@ -9979,9 +9979,9 @@ } }, "@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true }, "@parcel/watcher": { @@ -10106,93 +10106,93 @@ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==" }, "@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", "dev": true, "optional": true }, "@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", "dev": true, "optional": true }, "@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", "dev": true, "optional": true }, "@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", "dev": true, "optional": true }, "@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", "dev": true, "optional": true }, "@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", "dev": true, "optional": true }, "@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", "dev": true, "optional": true }, "@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", "dev": true, "optional": true }, "@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", "dev": true, "optional": true }, "@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", "dev": true, "optional": true }, "@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", "dev": true, "optional": true }, "@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", "dev": true, "optional": true }, "@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", "dev": true, "optional": true, "requires": { @@ -10200,16 +10200,16 @@ } }, "@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", "dev": true, "optional": true }, "@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", "dev": true, "optional": true }, @@ -14209,34 +14209,34 @@ } }, "rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", - "dev": true, - "requires": { - "@oxc-project/types": "=0.120.0", - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10", - "@rolldown/pluginutils": "1.0.0-rc.10" + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "dev": true, + "requires": { + "@oxc-project/types": "=0.122.0", + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11", + "@rolldown/pluginutils": "1.0.0-rc.11" }, "dependencies": { "@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", "dev": true } } @@ -15224,16 +15224,16 @@ } }, "vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", + "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "requires": { "fsevents": "~2.3.3", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", + "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index d49e5eaf..a876198c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,7 +60,7 @@ "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.4.0", - "vite": "^8.0.1", + "vite": "^8.0.2", "vitest": "^4.0.18" } } From 2b3f0156454a31171bb1c77ef8f3fad901d2f033 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:50:14 +0100 Subject: [PATCH 086/109] build(deps): bump djangorestframework in /requirements (#1112) Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.16.1 to 3.17.1. - [Release notes](https://github.com/encode/django-rest-framework/releases) - [Commits](https://github.com/encode/django-rest-framework/compare/3.16.1...3.17.1) --- updated-dependencies: - dependency-name: djangorestframework dependency-version: 3.17.1 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> --- requirements/project-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt index f8343321..9a0c0ad4 100644 --- a/requirements/project-requirements.txt +++ b/requirements/project-requirements.txt @@ -5,7 +5,7 @@ croniter==6.2.2 elasticsearch==9.3.0 Django==5.2.12 -djangorestframework==3.16.1 +djangorestframework==3.17.1 django-rest-email-auth==5.0.0 django-ses==4.7.2 From a3230123f4a3d4a9562185991a0fcd15b631ce49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:55:07 +0100 Subject: [PATCH 087/109] build(deps-dev): bump stylelint from 17.4.0 to 17.5.0 in /frontend (#1113) Bumps [stylelint](https://github.com/stylelint/stylelint) from 17.4.0 to 17.5.0. - [Release notes](https://github.com/stylelint/stylelint/releases) - [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md) - [Commits](https://github.com/stylelint/stylelint/compare/17.4.0...17.5.0) --- updated-dependencies: - dependency-name: stylelint dependency-version: 17.5.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 201 +++++++++++++++++++------------------ frontend/package.json | 2 +- 2 files changed, 104 insertions(+), 99 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e2381be5..225407c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,7 +40,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^29.0.1", "prettier": "^3.8.1", - "stylelint": "^17.4.0", + "stylelint": "^17.5.0", "vite": "^8.0.2", "vitest": "^4.0.18" } @@ -647,23 +647,6 @@ "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", - "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0" - }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", @@ -2854,6 +2837,32 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -6118,11 +6127,10 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" }, "node_modules/meow": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-14.0.0.tgz", - "integrity": "sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", "dev": true, - "license": "MIT", "engines": { "node": ">=20" }, @@ -8039,9 +8047,9 @@ } }, "node_modules/stylelint": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.4.0.tgz", - "integrity": "sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==", + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.5.0.tgz", + "integrity": "sha512-o/NS6zhsPZFmgUm5tXX4pVNg1XDOZSlucLdf2qow/lVn4JIyzZIQ5b3kad1ugqUj3GSIgr2u5lQw7X8rjqw33g==", "dev": true, "funding": [ { @@ -8056,21 +8064,21 @@ "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.0.27", + "@csstools/css-syntax-patches-for-csstree": "^1.0.29", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", - "cosmiconfig": "^9.0.0", + "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", - "globby": "^16.1.0", + "globby": "^16.1.1", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", @@ -8078,15 +8086,15 @@ "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", - "meow": "^14.0.0", + "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", - "string-width": "^8.1.1", + "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", @@ -8099,41 +8107,38 @@ "node": ">=20.19.0" } }, - "node_modules/stylelint/node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "node_modules/stylelint/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", "dev": true, - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "peerDependencies": { - "typescript": ">=4.9.5" + "css-tree": "^3.2.1" }, "peerDependenciesMeta": { - "typescript": { + "css-tree": { "optional": true } } }, "node_modules/stylelint/node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, - "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -8172,11 +8177,10 @@ } }, "node_modules/stylelint/node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true }, "node_modules/stylis": { "version": "4.0.13", @@ -9639,12 +9643,6 @@ "dev": true, "requires": {} }, - "@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", - "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", - "dev": true - }, "@csstools/css-tokenizer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", @@ -11083,6 +11081,18 @@ "toggle-selection": "^1.0.6" } }, + "cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "requires": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + } + }, "cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -13373,9 +13383,9 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" }, "meow": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-14.0.0.tgz", - "integrity": "sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", "dev": true }, "merge2": { @@ -14688,28 +14698,28 @@ "peer": true }, "stylelint": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.4.0.tgz", - "integrity": "sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==", + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.5.0.tgz", + "integrity": "sha512-o/NS6zhsPZFmgUm5tXX4pVNg1XDOZSlucLdf2qow/lVn4JIyzZIQ5b3kad1ugqUj3GSIgr2u5lQw7X8rjqw33g==", "dev": true, "requires": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.0.27", + "@csstools/css-syntax-patches-for-csstree": "^1.0.29", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", - "cosmiconfig": "^9.0.0", + "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", - "globby": "^16.1.0", + "globby": "^16.1.1", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", @@ -14717,41 +14727,36 @@ "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", - "meow": "^14.0.0", + "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", - "string-width": "^8.1.1", + "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.0" }, "dependencies": { - "cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", "dev": true, - "requires": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - } + "requires": {} }, "css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "requires": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" } }, "file-entry-cache": { @@ -14781,9 +14786,9 @@ "dev": true }, "mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true } } diff --git a/frontend/package.json b/frontend/package.json index a876198c..4b78fd70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,7 +59,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^29.0.1", "prettier": "^3.8.1", - "stylelint": "^17.4.0", + "stylelint": "^17.5.0", "vite": "^8.0.2", "vitest": "^4.0.18" } From 43be36e472ec9857487140b2b246ba7946b5e4c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:02:02 +0100 Subject: [PATCH 088/109] build(deps-dev): bump @vitejs/plugin-react in /frontend (#1115) Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.2.0 to 6.0.1. - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.1/packages/plugin-react) --- updated-dependencies: - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 282 ++++++++++++------------------------- frontend/package.json | 2 +- 2 files changed, 88 insertions(+), 196 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 225407c4..d02d472e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", @@ -145,6 +145,7 @@ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -154,6 +155,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -182,12 +184,14 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "peer": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -213,6 +217,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -228,6 +233,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -236,6 +242,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -243,7 +250,8 @@ "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "peer": true }, "node_modules/@babel/helper-globals": { "version": "7.28.0", @@ -272,6 +280,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -316,6 +325,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -325,6 +335,7 @@ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -362,38 +373,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -1016,6 +995,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -1685,11 +1665,10 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true, - "license": "MIT" + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -1810,48 +1789,6 @@ "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", "dev": true }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.17.1", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.17.1.tgz", - "integrity": "sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.3.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1995,23 +1932,28 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", "dev": true, "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "@rolldown/pluginutils": "1.0.0-rc.7" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/@vitest/coverage-v8": { @@ -2539,6 +2481,7 @@ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "license": "Apache-2.0", + "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -2612,6 +2555,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2713,7 +2657,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" + "license": "CC-BY-4.0", + "peer": true }, "node_modules/chai": { "version": "6.2.2", @@ -3401,7 +3346,8 @@ "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -3628,6 +3574,7 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4502,6 +4449,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -5634,6 +5582,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -6325,7 +6274,8 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/normalize-path": { "version": "3.0.0", @@ -6948,16 +6898,6 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -8695,6 +8635,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -9330,12 +9271,14 @@ "@babel/compat-data": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==" + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "peer": true }, "@babel/core": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -9357,12 +9300,14 @@ "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "peer": true }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "peer": true } } }, @@ -9382,6 +9327,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "peer": true, "requires": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -9394,6 +9340,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "peer": true, "requires": { "yallist": "^3.0.2" } @@ -9401,12 +9348,14 @@ "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "peer": true }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "peer": true } } }, @@ -9428,6 +9377,7 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "peer": true, "requires": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -9452,12 +9402,14 @@ "@babel/helper-validator-option": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "peer": true }, "@babel/helpers": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "peer": true, "requires": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -9479,24 +9431,6 @@ "@babel/helper-plugin-utils": "^7.22.5" } }, - "@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, - "@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.27.1" - } - }, "@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -9898,6 +9832,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "peer": true, "requires": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -10212,9 +10147,9 @@ "optional": true }, "@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true }, "@rtsao/scc": { @@ -10307,47 +10242,6 @@ "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", "dev": true }, - "@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "requires": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", - "dev": true, - "requires": { - "@babel/types": "^7.0.0" - } - }, - "@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", - "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "@types/babel__traverse": { - "version": "7.17.1", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.17.1.tgz", - "integrity": "sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA==", - "dev": true, - "requires": { - "@babel/types": "^7.3.0" - } - }, "@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -10486,17 +10380,12 @@ "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", "dev": true, "requires": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "@rolldown/pluginutils": "1.0.0-rc.7" } }, "@vitest/coverage-v8": { @@ -10883,7 +10772,8 @@ "baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==" + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "peer": true }, "bidi-js": { "version": "1.0.3", @@ -10923,6 +10813,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10983,7 +10874,8 @@ "caniuse-lite": { "version": "1.0.30001769", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==" + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "peer": true }, "chai": { "version": "6.2.2", @@ -11494,7 +11386,8 @@ "electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==" + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "peer": true }, "emoji-regex": { "version": "9.2.2", @@ -11674,7 +11567,8 @@ "escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "peer": true }, "escape-string-regexp": { "version": "4.0.0", @@ -12317,7 +12211,8 @@ "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "peer": true }, "get-east-asian-width": { "version": "1.5.0", @@ -13088,7 +12983,8 @@ "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "peer": true }, "jsonp": { "version": "0.2.1", @@ -13514,7 +13410,8 @@ "node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==" + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "peer": true }, "normalize-path": { "version": "3.0.0", @@ -13942,12 +13839,6 @@ } } }, - "react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true - }, "react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -15143,6 +15034,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "peer": true, "requires": { "escalade": "^3.2.0", "picocolors": "^1.1.1" diff --git a/frontend/package.json b/frontend/package.json index 4b78fd70..7c67cf62 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,7 +49,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", From 03855bf58519644c70f0dd26ea75bbf79bff4cd0 Mon Sep 17 00:00:00 2001 From: SupRaKoshti Date: Thu, 26 Mar 2026 01:01:54 +0530 Subject: [PATCH 089/109] Non-root user using gosu in app container. closes #1102 (#1106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhancement: run app container as non-root user using gosu - install gosu in Dockerfile alongside existing runtime dependencies - add mkdir -p /run/gunicorn and chown -R 2000:82 /var/log/greedybear /run in entrypoint_gunicorn.sh to pre-create socket directory and fix ownership of any root-owned files from previous deployments - replace exec "$@" with exec gosu www-data "$@" at the end of entrypoint_gunicorn.sh so gunicorn runs as www-data (uid 2000) instead of root the entrypoint still starts as root so chown, migrations and collectstatic continue to work correctly. gosu drops privileges to www-data right before gunicorn starts, ensuring gunicorn and all its workers run as non-root for their entire lifetime. existing deployments with log files owned by root are automatically fixed by the chown step on every restart — no manual migration needed. * Changes: - Scope chown to /var/log/greedybear /run/ginucorn only (least privilege) - Skip gosu in dev mode (DJANGO_TEST_SERVER=True) --- docker/Dockerfile | 2 +- docker/entrypoint_gunicorn.sh | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f764a361..0d1908b6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,7 +29,7 @@ WORKDIR $PYTHONPATH # - libgomp1 is required for model training # - curl is used for healthcheck RUN apt-get update && apt-get install -y --no-install-recommends \ - libgomp1 curl \ + libgomp1 curl gosu \ && rm -rf /var/lib/apt/lists/* # Install python packages diff --git a/docker/entrypoint_gunicorn.sh b/docker/entrypoint_gunicorn.sh index fb03fb07..d1f8b98b 100755 --- a/docker/entrypoint_gunicorn.sh +++ b/docker/entrypoint_gunicorn.sh @@ -18,9 +18,10 @@ python manage.py collectstatic --noinput --clear --verbosity 0 # Ensure log directories exist (volumes may persist from older builds) mkdir -p /var/log/greedybear/gunicorn +mkdir -p /run/gunicorn # Fix log file ownership (manage.py commands above run as root and may create new log files) -chown -R 2000:82 /var/log/greedybear +chown -R 2000:82 /var/log/greedybear /run/gunicorn # Obtain the current GreedyBear version number . /opt/deploy/greedybear/docker/.version @@ -32,4 +33,10 @@ echo "DEBUG: $DEBUG" echo "DJANGO_TEST_SERVER: $DJANGO_TEST_SERVER" echo "------------------------------" -exec "$@" +if [ "$DJANGO_TEST_SERVER" = "True" ]; then + # Dev mode: run as root (needed for hot-reload on volume-mounted source) + exec "$@" +else + # Production mode: drop privileges to www-data before starting Gunicorn + exec gosu www-data "$@" +fi From 9e9a700194e0e56928c1d6b6f1461032f48e5318 Mon Sep 17 00:00:00 2001 From: Piyush Gautam Date: Thu, 26 Mar 2026 01:21:00 +0530 Subject: [PATCH 090/109] tests: add behavioral coverage for dashboard/utils/charts.jsx (#1109) * tests: add behavioral coverage for dashboard/utils/charts.jsx * fix: correct import paths and remove unused screen import * tests: extend charts.test.jsx with missing edge cases * chore: remove duplicate charts test file from utils --- .../components/dashboard/charts.test.jsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/frontend/tests/components/dashboard/charts.test.jsx b/frontend/tests/components/dashboard/charts.test.jsx index 9807ed38..96e884af 100644 --- a/frontend/tests/components/dashboard/charts.test.jsx +++ b/frontend/tests/components/dashboard/charts.test.jsx @@ -95,4 +95,54 @@ describe("Charts Components", () => { const bars = screen.getAllByTestId(/^bar-/); expect(bars.length).toBeGreaterThan(0); }); + + test("FeedsTypesChart returns null for empty data", () => { + vi.mocked(AnyChartWidget).mockImplementationOnce(({ componentsFn }) => ( +
{componentsFn([])}
+ )); + const { container } = render(); + expect(container.firstChild.children.length).toBe(0); + }); + + test("FeedsTypesChart returns null for undefined data", () => { + vi.mocked(AnyChartWidget).mockImplementationOnce(({ componentsFn }) => ( +
{componentsFn(undefined)}
+ )); + const { container } = render(); + expect(container.firstChild.children.length).toBe(0); + }); + + test("FeedsSourcesChart Area has correct dataKey", () => { + render(); + expect(screen.getByTestId("area-Sources")).toBeInTheDocument(); + }); + + test("FeedsDownloadsChart Area has correct dataKey", () => { + render(); + expect(screen.getByTestId("area-Downloads")).toBeInTheDocument(); + }); + + test("EnrichmentSourcesChart Area has correct dataKey", () => { + render(); + expect(screen.getByTestId("area-Sources")).toBeInTheDocument(); + }); + + test("EnrichmentRequestsChart Area has correct dataKey", () => { + render(); + expect(screen.getByTestId("area-Requests")).toBeInTheDocument(); + }); + + test("FeedsTypesChart only reads feed types from first element of respData", () => { + vi.mocked(AnyChartWidget).mockImplementationOnce(({ componentsFn }) => { + const respData = [ + { date: "2024-01-01", ssh: 5 }, + { date: "2024-01-02", ssh: 8, telnet: 3 }, + ]; + return
{componentsFn(respData)}
; + }); + render(); + const bars = screen.getAllByTestId(/^bar-/); + expect(bars).toHaveLength(1); + expect(screen.getByTestId("bar-ssh")).toBeInTheDocument(); + }); }); From 193377108826b66915926ea1eacb3d925ed2f409 Mon Sep 17 00:00:00 2001 From: Manik Date: Thu, 26 Mar 2026 17:53:44 +0530 Subject: [PATCH 091/109] perf: optimize N+1 queries and bulk database operations in extraction pipeline. Closes #1073 (#1118) * perf: optimize N+1 queries and bulk database operations in extraction pipeline. Closes #1073 * revert the changes --- .../cronjobs/extraction/ioc_processor.py | 6 +- .../cronjobs/extraction/strategies/tanner.py | 23 +-- greedybear/cronjobs/extraction/utils.py | 6 +- greedybear/cronjobs/repositories/ioc.py | 27 +++- greedybear/cronjobs/reverse_dns.py | 22 +-- tests/test_ioc_processor.py | 3 +- tests/test_ioc_repository.py | 41 ++++- tests/test_reverse_dns.py | 33 +--- tests/test_tanner_strategy.py | 147 +++++++++--------- 9 files changed, 173 insertions(+), 135 deletions(-) diff --git a/greedybear/cronjobs/extraction/ioc_processor.py b/greedybear/cronjobs/extraction/ioc_processor.py index 3bdd03ed..fd79c721 100644 --- a/greedybear/cronjobs/extraction/ioc_processor.py +++ b/greedybear/cronjobs/extraction/ioc_processor.py @@ -64,8 +64,7 @@ def add_ioc( # Add sensors to newly saved IOC from temporary attribute. # (See greedybear/cronjobs/extraction/utils.py for why we use this) if hasattr(ioc, "_sensors_to_add") and ioc._sensors_to_add: - for sensor in ioc._sensors_to_add: - ioc_record.sensors.add(sensor) + ioc_record.sensors.add(*ioc._sensors_to_add) else: # Update - sensors handled inside _merge_iocs self.log.debug(f"{ioc} is already known - updating record") ioc_record = self._merge_iocs(ioc_record, ioc) @@ -115,8 +114,7 @@ def _merge_iocs(self, existing: IOC, new: IOC) -> IOC: # Add sensors from new IOC (existing is already saved, so ManyToMany works). # We retrieve sensors from the temporary attribute of the input IOC object. if hasattr(new, "_sensors_to_add") and new._sensors_to_add: - for sensor in new._sensors_to_add: - existing.sensors.add(sensor) + existing.sensors.add(*new._sensors_to_add) return existing diff --git a/greedybear/cronjobs/extraction/strategies/tanner.py b/greedybear/cronjobs/extraction/strategies/tanner.py index 3b86135e..73411548 100644 --- a/greedybear/cronjobs/extraction/strategies/tanner.py +++ b/greedybear/cronjobs/extraction/strategies/tanner.py @@ -11,8 +11,8 @@ parse_timestamp, threatfox_submission, ) -from greedybear.cronjobs.repositories import IocRepository, SensorRepository -from greedybear.models import IOC, Tag +from greedybear.cronjobs.repositories import IocRepository, SensorRepository, TagRepository +from greedybear.models import IOC # Attack classification regex patterns. # Each pattern targets common signatures of its respective web attack class. @@ -83,6 +83,8 @@ def __init__( sensor_repo: SensorRepository, ): super().__init__(honeypot, ioc_repo, sensor_repo) + self.tag_repo = TagRepository() + self.attack_tags_set = set() self.attack_tags_added = 0 self.rfi_hostnames_added = 0 @@ -97,8 +99,13 @@ def extract_from_hits(self, hits: list[dict]) -> None: Args: hits: List of Elasticsearch hit dictionaries to process. """ + self.attack_tags_set = set() self._get_scanners(hits) self._classify_attacks(hits) + + tag_entries = [{"ioc_id": ioc_id, "key": "attack_type", "value": attack_type} for ioc_id, attack_type in self.attack_tags_set] + self.attack_tags_added += self.tag_repo.add_tags(TANNER_SOURCE, tag_entries) + self.log.info( f"added {len(self.ioc_records)} scanners, {self.attack_tags_added} attack tags, {self.rfi_hostnames_added} RFI hostnames from {self.honeypot}" ) @@ -210,16 +217,10 @@ def _add_attack_tags(self, ioc_record: IOC, attack_types: list[str]) -> None: ioc_record: Persisted IOC instance to tag. attack_types: List of attack type strings to store. """ + if not ioc_record.id: + return for attack_type in attack_types: - _, created = Tag.objects.get_or_create( - ioc=ioc_record, - key="attack_type", - value=attack_type, - source=TANNER_SOURCE, - ) - if created: - self.attack_tags_added += 1 - self.log.info(f"tagged {ioc_record.name} with attack_type={attack_type}") + self.attack_tags_set.add((ioc_record.id, attack_type)) def _extract_rfi_hostnames(self, hit: dict, scanner_ip: str, request_text: str) -> None: """ diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index 556c63ff..efaf16c9 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -288,7 +288,11 @@ def threatfox_submission(ioc_record: IOC, related_urls: list, log: Logger) -> No headers = {"Auth-Key": settings.THREATFOX_API_KEY} log.info(f"submitting IOC {urls_to_submit} to Threatfox") - seen_honeypots = [hp.name for hp in ioc_record.general_honeypot.all()] + if hasattr(ioc_record, "_seen_honeypots"): + seen_honeypots = ioc_record._seen_honeypots + else: + seen_honeypots = [hp.name for hp in ioc_record.general_honeypot.all()] + seen_honeypots_str = ", ".join(seen_honeypots) json_data = { diff --git a/greedybear/cronjobs/repositories/ioc.py b/greedybear/cronjobs/repositories/ioc.py index 0888c1bb..e0f14b86 100644 --- a/greedybear/cronjobs/repositories/ioc.py +++ b/greedybear/cronjobs/repositories/ioc.py @@ -36,14 +36,24 @@ def add_honeypot_to_ioc(self, honeypot_name: str, ioc: IOC) -> IOC: The updated IOC instance. """ normalized_name = self._normalize_name(honeypot_name) - honeypot_set = {self._normalize_name(hp.name) for hp in ioc.general_honeypot.all()} + + if hasattr(ioc, "_seen_honeypots"): + honeypot_set = set(ioc._seen_honeypots) + else: + honeypot_set = {self._normalize_name(hp.name) for hp in ioc.general_honeypot.all()} + if normalized_name not in honeypot_set: self.log.debug(f"adding honeypot {honeypot_name} to IoC {ioc}") honeypot = self._honeypot_cache.get(normalized_name) if honeypot is not None: ioc.general_honeypot.add(honeypot) + honeypot_set.add(normalized_name) else: self.log.error(f"Honeypot '{honeypot_name}' not found in cache; skipping association for IOC {ioc}") + + # Cache the current honeypot names explicitly on the IOC + # to avoid N+1 queries when looking up `ioc.general_honeypot.all()` downstream + ioc._seen_honeypots = list(honeypot_set) return ioc def create_honeypot(self, honeypot_name: str) -> GeneralHoneypot: @@ -271,3 +281,18 @@ def update_ioc_reputation(self, ip_address: str, reputation: str) -> bool: return True except IOC.DoesNotExist: return False + + def bulk_update_ioc_reputation(self, ip_addresses: list[str], reputation: str) -> int: + """ + Bulk update the IP reputation for a list of IOCs. + + Args: + ip_addresses: List of IP addresses to update. + reputation: New reputation value. + + Returns: + Number of IOC records updated. + """ + if not ip_addresses: + return 0 + return IOC.objects.filter(name__in=ip_addresses).update(ip_reputation=reputation) diff --git a/greedybear/cronjobs/reverse_dns.py b/greedybear/cronjobs/reverse_dns.py index 974c393b..42168e37 100644 --- a/greedybear/cronjobs/reverse_dns.py +++ b/greedybear/cronjobs/reverse_dns.py @@ -69,7 +69,7 @@ def run(self) -> None: # Only tag IPs that have actual PTR records — IPs without PTR # are left untagged so they can be rechecked on future runs. tag_entries = [] - total_matched = 0 + matched_ips = [] for ip, ptr in ptr_results.items(): if not ptr: @@ -78,11 +78,14 @@ def run(self) -> None: tag_entries.append({"ioc_id": ip_to_id[ip], "key": "ptr_record", "value": ptr}) if self._matches_scanner_domain(ptr): - self._update_ioc(ip) - total_matched += 1 + matched_ips.append(ip) + + if matched_ips: + updated_count = self.ioc_repo.bulk_update_ioc_reputation(matched_ips, IpReputation.MASS_SCANNER.value) + self.log.info(f"Marked {updated_count} IPs as mass scanners via rDNS") created_count = self.tag_repo.add_tags(SOURCE_NAME, tag_entries) - self.log.info(f"Reverse DNS check completed. Checked {len(ptr_results)} IPs, created {created_count} tags, {total_matched} matched mass scanners") + self.log.info(f"Reverse DNS check completed. Checked {len(ptr_results)} IPs, created {created_count} tags, {len(matched_ips)} matched mass scanners") def _get_candidates(self): """ @@ -177,14 +180,3 @@ def _matches_scanner_domain(hostname: str) -> bool: if hostname_lower == domain or hostname_lower.endswith("." + domain): return True return False - - def _update_ioc(self, ip_address: str): - """ - Update the IP reputation of an existing IOC to mark it as a mass scanner. - - Args: - ip_address: IP address to update. - """ - updated = self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.MASS_SCANNER) - if updated: - self.log.info(f"Marked {ip_address} as {IpReputation.MASS_SCANNER} via rDNS") diff --git a/tests/test_ioc_processor.py b/tests/test_ioc_processor.py index 1c958b26..2dbbe688 100644 --- a/tests/test_ioc_processor.py +++ b/tests/test_ioc_processor.py @@ -111,8 +111,7 @@ def test_adds_sensors_from_attribute(self): self.processor.add_ioc(ioc, attack_type=SCANNER) - ioc.sensors.add.assert_any_call(sensor1) - ioc.sensors.add.assert_any_call(sensor2) + ioc.sensors.add.assert_called_once_with(sensor1, sensor2) def test_updates_days_seen_on_add(self): self.mock_sensor_repo.cache = {} diff --git a/tests/test_ioc_repository.py b/tests/test_ioc_repository.py index 74a1fb4e..a3260898 100644 --- a/tests/test_ioc_repository.py +++ b/tests/test_ioc_repository.py @@ -108,6 +108,8 @@ def test_add_honeypot_to_ioc_adds_new_honeypot(self): def test_add_honeypot_to_ioc_cache_miss_logs_error(self): """Honeypot created after repo init is not in cache; association is skipped and error is logged.""" ioc = IOC.objects.create(name="1.2.3.4", type="ip") + # Force cache initialization before creating the new honeypot + _ = self.repo._honeypot_cache GeneralHoneypot.objects.create(name="NewPot", active=True) with self.assertLogs("greedybear.cronjobs.repositories.ioc", level="ERROR") as cm: result = self.repo.add_honeypot_to_ioc("NewPot", ioc) @@ -448,7 +450,7 @@ def test_bulk_update_scores_with_custom_batch_size(self): # --- Tests for N+1 fix --- def test_honeypot_cache_stores_generalhoneypot_objects(self): - """_honeypot_cache must store GeneralHoneypot instances, not booleans.""" + """honeypot_cache must store GeneralHoneypot instances, not booleans.""" self.assertGreater( len(self.repo._honeypot_cache), 0, @@ -485,6 +487,7 @@ def test_add_honeypot_to_ioc_uses_cache_not_db(self): # honeypot lookup uses in-memory cache (0 queries), only the M2M INSERT fires IOC.objects.create(name="6.6.6.6", type="ip") ioc2_fetched = self.repo.get_ioc_by_name("6.6.6.6") + _ = self.repo._honeypot_cache # Force cache load to isolate M2M INSERT queries with self.assertNumQueries(1): # only M2M INSERT result2 = self.repo.add_honeypot_to_ioc("Cowrie", ioc2_fetched) self.assertIn(cowrie_hp, result2.general_honeypot.all()) @@ -659,3 +662,39 @@ def test_update_ioc_reputation_updates_existing(self): def test_update_ioc_reputation_returns_false_for_missing(self): result = self.repo.update_ioc_reputation("9.9.9.9", IpReputation.MASS_SCANNER) self.assertFalse(result) + + def test_bulk_update_ioc_reputation_returns_zero_for_empty_list(self): + result = self.repo.bulk_update_ioc_reputation([], IpReputation.MASS_SCANNER.value) + self.assertEqual(result, 0) + + def test_bulk_update_ioc_reputation_updates_multiple_iocs(self): + IOC.objects.create(name="10.0.0.1", type="ip", ip_reputation="") + IOC.objects.create(name="10.0.0.2", type="ip", ip_reputation="") + + result = self.repo.bulk_update_ioc_reputation(["10.0.0.1", "10.0.0.2"], IpReputation.MASS_SCANNER.value) + + self.assertEqual(result, 2) + self.assertEqual(IOC.objects.get(name="10.0.0.1").ip_reputation, IpReputation.MASS_SCANNER.value) + self.assertEqual(IOC.objects.get(name="10.0.0.2").ip_reputation, IpReputation.MASS_SCANNER.value) + + def test_bulk_update_ioc_reputation_ignores_nonexistent_ips(self): + IOC.objects.create(name="10.0.0.3", type="ip", ip_reputation="") + + result = self.repo.bulk_update_ioc_reputation(["10.0.0.3", "254.254.254.254"], IpReputation.MASS_SCANNER.value) + + self.assertEqual(result, 1) + self.assertEqual(IOC.objects.get(name="10.0.0.3").ip_reputation, IpReputation.MASS_SCANNER.value) + + def test_bulk_update_ioc_reputation_does_not_affect_other_iocs(self): + IOC.objects.create(name="10.0.0.4", type="ip", ip_reputation="") + IOC.objects.create(name="10.0.0.5", type="ip", ip_reputation="") + + self.repo.bulk_update_ioc_reputation(["10.0.0.4"], IpReputation.MASS_SCANNER.value) + + self.assertEqual(IOC.objects.get(name="10.0.0.4").ip_reputation, IpReputation.MASS_SCANNER.value) + self.assertEqual(IOC.objects.get(name="10.0.0.5").ip_reputation, "") + + def test_bulk_update_ioc_reputation_returns_zero_when_none_match(self): + # Non-empty list but IPs don't exist in DB + result = self.repo.bulk_update_ioc_reputation(["8.8.8.8", "8.8.4.4"], IpReputation.MASS_SCANNER.value) + self.assertEqual(result, 0) diff --git a/tests/test_reverse_dns.py b/tests/test_reverse_dns.py index cebcebc5..ef2af5cf 100644 --- a/tests/test_reverse_dns.py +++ b/tests/test_reverse_dns.py @@ -133,26 +133,26 @@ def test_does_not_store_empty_ptr(self): def test_updates_reputation_on_scanner_match(self): """Matching PTR should trigger a reputation update to 'mass scanner'.""" - self.mock_ioc_repo.update_ioc_reputation.return_value = True + self.mock_ioc_repo.bulk_update_ioc_reputation.return_value = 1 with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("probe.censys.io")): self.cron.run() - self.mock_ioc_repo.update_ioc_reputation.assert_called_with(self.candidate_ioc.name, IpReputation.MASS_SCANNER) + self.mock_ioc_repo.bulk_update_ioc_reputation.assert_called_with([self.candidate_ioc.name], IpReputation.MASS_SCANNER) def test_no_reputation_update_on_non_scanner_ptr(self): """Non-scanner PTR records should not cause reputation updates.""" with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("mail.google.com")): self.cron.run() - self.mock_ioc_repo.update_ioc_reputation.assert_not_called() + self.mock_ioc_repo.bulk_update_ioc_reputation.assert_not_called() def test_no_reputation_update_on_empty_ptr(self): """Empty PTR results should not cause reputation updates.""" with patch.object(self.cron, "_resolve_batch", side_effect=self._mock_resolve("")): self.cron.run() - self.mock_ioc_repo.update_ioc_reputation.assert_not_called() + self.mock_ioc_repo.bulk_update_ioc_reputation.assert_not_called() def test_candidates_ordered_by_persistence(self): """Most persistent IPs should be checked first.""" @@ -350,28 +350,3 @@ def test_all_scanner_domains(self): for domain in MASS_SCANNER_DOMAINS: self.assertTrue(ReverseDNSCron._matches_scanner_domain(domain), f"{domain} should match") self.assertTrue(ReverseDNSCron._matches_scanner_domain(f"probe.{domain}"), f"probe.{domain} should match") - - -class TestReverseDNSCronUpdateIoc(CustomTestCase): - """Tests for _update_ioc method.""" - - def setUp(self): - self.mock_ioc_repo = Mock() - self.cron = ReverseDNSCron(tag_repo=Mock(), ioc_repo=self.mock_ioc_repo) - self.cron.log = Mock() - - def test_update_ioc_success(self): - self.mock_ioc_repo.update_ioc_reputation.return_value = True - - self.cron._update_ioc("1.2.3.4") - - self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("1.2.3.4", IpReputation.MASS_SCANNER) - self.cron.log.info.assert_called_once() - - def test_update_ioc_not_found(self): - self.mock_ioc_repo.update_ioc_reputation.return_value = False - - self.cron._update_ioc("9.9.9.9") - - self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("9.9.9.9", IpReputation.MASS_SCANNER) - self.cron.log.info.assert_not_called() diff --git a/tests/test_tanner_strategy.py b/tests/test_tanner_strategy.py index f8051ac9..7e31f95c 100644 --- a/tests/test_tanner_strategy.py +++ b/tests/test_tanner_strategy.py @@ -4,7 +4,6 @@ from greedybear.cronjobs.extraction.strategies.tanner import ( TANNER_ATTACK_PATTERNS, TANNER_HONEYPOT, - TANNER_SOURCE, TannerExtractionStrategy, ) @@ -237,89 +236,95 @@ def setUp(self): ) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_sqli_tagged(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_sqli_tagged(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 hits = [{"src_ip": "1.2.3.4", "url": "/page?id=1 UNION SELECT * FROM users"}] self.strategy.extract_from_hits(hits) - mock_tag_objects.get_or_create.assert_any_call( - ioc=mock_ioc_record, - key="attack_type", - value="sqli", - source=TANNER_SOURCE, - ) + # check if tag was added + found = False + for call in mock_add_tags.call_args_list: + source, tags = call[0] + for tag in tags: + if tag["ioc_id"] == mock_ioc_record.id and tag["value"] == "sqli": + found = True + break + self.assertTrue(found, "Tag sqli not added") @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_sqli_plus_encoded_detected(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_sqli_plus_encoded_detected(self, mock_add_tags, mock_iocs_from_hits): """UNION+SELECT (+ as space in query string) must be detected as SQLi.""" mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 hits = [{"src_ip": "1.2.3.4", "url": "/page?id=1+UNION+SELECT+*+FROM+users"}] self.strategy.extract_from_hits(hits) - mock_tag_objects.get_or_create.assert_any_call( - ioc=mock_ioc_record, - key="attack_type", - value="sqli", - source=TANNER_SOURCE, - ) + # check if tag was added + found = False + for call in mock_add_tags.call_args_list: + source, tags = call[0] + for tag in tags: + if tag["ioc_id"] == mock_ioc_record.id and tag["value"] == "sqli": + found = True + break + self.assertTrue(found, "Tag sqli not added") @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_mixed_attacks_produce_multiple_tags(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_mixed_attacks_produce_multiple_tags(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 hits = [{"src_ip": "1.2.3.4", "url": "/page?file=../../../etc/passwd&q="}] self.strategy.extract_from_hits(hits) - tag_values = [call[1]["value"] for call in mock_tag_objects.get_or_create.call_args_list] + tag_values = [tag["value"] for call in mock_add_tags.call_args_list for tag in call[0][1]] self.assertIn("lfi", tag_values) self.assertIn("xss", tag_values) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_benign_request_no_tags(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_benign_request_no_tags(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] hits = [{"src_ip": "1.2.3.4", "url": "/index.html?page=about"}] self.strategy.extract_from_hits(hits) - mock_tag_objects.get_or_create.assert_not_called() + mock_add_tags.assert_called_once_with("tanner", []) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_missing_src_ip_skipped(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_missing_src_ip_skipped(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] hits = [{"url": "/page?id=1 UNION SELECT *"}] self.strategy.extract_from_hits(hits) - mock_tag_objects.get_or_create.assert_not_called() + mock_add_tags.assert_called_once_with("tanner", []) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_unknown_scanner_ip_skipped(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_unknown_scanner_ip_skipped(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] self.mock_ioc_repo.get_ioc_by_name.return_value = None hits = [{"src_ip": "9.9.9.9", "url": "/page?id=1 UNION SELECT *"}] self.strategy.extract_from_hits(hits) - mock_tag_objects.get_or_create.assert_not_called() + mock_add_tags.assert_called_once_with("tanner", []) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_new_tag_increments_counter(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_new_tag_increments_counter(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 hits = [{"src_ip": "1.2.3.4", "url": "/page?id=1; SLEEP(5)--"}] self.strategy.extract_from_hits(hits) @@ -327,12 +332,12 @@ def test_new_tag_increments_counter(self, mock_tag_objects, mock_iocs_from_hits) self.assertGreater(self.strategy.attack_tags_added, 0) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_existing_tag_not_counted(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_existing_tag_not_counted(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), False) + mock_add_tags.return_value = 0 hits = [{"src_ip": "1.2.3.4", "url": "/page?id=1 UNION SELECT *"}] self.strategy.extract_from_hits(hits) @@ -351,13 +356,13 @@ def setUp(self): @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") @patch("greedybear.cronjobs.extraction.strategies.tanner.threatfox_submission") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_hostname_as_payload_request(self, mock_tag_objects, mock_threatfox, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_hostname_as_payload_request(self, mock_add_tags, mock_threatfox, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc_record = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc_record) @@ -372,8 +377,8 @@ def test_rfi_hostname_as_payload_request(self, mock_tag_objects, mock_threatfox, @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") @patch("greedybear.cronjobs.extraction.strategies.tanner.threatfox_submission") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_links_scanner_to_hostname(self, mock_tag_objects, mock_threatfox, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_links_scanner_to_hostname(self, mock_add_tags, mock_threatfox, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] scanner_record = self._create_mock_ioc("1.2.3.4") hostname_record = self._create_mock_ioc("evil.com", ioc_type="domain") @@ -383,7 +388,7 @@ def test_rfi_links_scanner_to_hostname(self, mock_tag_objects, mock_threatfox, m "evil.com": hostname_record, }.get(name) - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 self.strategy.ioc_processor.add_ioc = Mock(return_value=hostname_record) hits = [{"src_ip": "1.2.3.4", "url": "/page?file=include http://evil.com/shell.php"}] @@ -393,12 +398,12 @@ def test_rfi_links_scanner_to_hostname(self, mock_tag_objects, mock_threatfox, m hostname_record.related_ioc.add.assert_called_with(scanner_record) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_same_hostname_deduplicated(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_same_hostname_deduplicated(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) @@ -411,12 +416,12 @@ def test_rfi_same_hostname_deduplicated(self, mock_tag_objects, mock_iocs_from_h self.assertEqual(len(payload_calls), 1) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_counter(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_counter(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) @@ -427,12 +432,12 @@ def test_rfi_counter(self, mock_tag_objects, mock_iocs_from_hits): self.assertGreater(self.strategy.rfi_hostnames_added, 0) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_invalid_url_no_crash(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_invalid_url_no_crash(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 self.strategy.ioc_processor.add_ioc = Mock(return_value=None) @@ -442,12 +447,12 @@ def test_rfi_invalid_url_no_crash(self, mock_tag_objects, mock_iocs_from_hits): self.assertEqual(self.strategy.rfi_hostnames_added, 0) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_sensor_attached_to_ioc(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_sensor_attached_to_ioc(self, mock_add_tags, mock_iocs_from_hits): mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) @@ -465,13 +470,13 @@ def test_rfi_sensor_attached_to_ioc(self, mock_tag_objects, mock_iocs_from_hits) self.assertEqual(ioc_arg._sensors_to_add, [mock_sensor]) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_outer_param_stripped_from_url(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_outer_param_stripped_from_url(self, mock_add_tags, mock_iocs_from_hits): """URL without a query string: '&' is an outer request separator and must be stripped.""" mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) @@ -486,13 +491,13 @@ def test_rfi_outer_param_stripped_from_url(self, mock_tag_objects, mock_iocs_fro self.assertEqual(submitted_url, "http://evil.com/shell.php") @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_query_string_params_preserved(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_query_string_params_preserved(self, mock_add_tags, mock_iocs_from_hits): """URL with a real query string: '&' within the query must NOT be stripped.""" mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) @@ -507,13 +512,13 @@ def test_rfi_query_string_params_preserved(self, mock_tag_objects, mock_iocs_fro self.assertEqual(submitted_url, full_url) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_trailing_delimiters_stripped_from_url(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_trailing_delimiters_stripped_from_url(self, mock_add_tags, mock_iocs_from_hits): """Trailing ')', ',', ';' characters must be stripped from extracted URLs.""" mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) @@ -527,13 +532,13 @@ def test_rfi_trailing_delimiters_stripped_from_url(self, mock_tag_objects, mock_ self.assertEqual(submitted_url, "http://evil.com/shell.php") @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_timestamp_set_on_ioc(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_timestamp_set_on_ioc(self, mock_add_tags, mock_iocs_from_hits): """first_seen and last_seen on the RFI IOC must match the hit's @timestamp.""" mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) @@ -551,13 +556,13 @@ def test_rfi_timestamp_set_on_ioc(self, mock_tag_objects, mock_iocs_from_hits): self.assertEqual(ioc_arg.last_seen, expected_time) @patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits") - @patch("greedybear.cronjobs.extraction.strategies.tanner.Tag.objects") - def test_rfi_missing_timestamp_no_crash(self, mock_tag_objects, mock_iocs_from_hits): + @patch("greedybear.cronjobs.extraction.strategies.tanner.TagRepository.add_tags") + def test_rfi_missing_timestamp_no_crash(self, mock_add_tags, mock_iocs_from_hits): """Missing @timestamp must not crash; IOC is still created without explicit timestamps.""" mock_iocs_from_hits.return_value = [] mock_ioc_record = self._create_mock_ioc("1.2.3.4") self.mock_ioc_repo.get_ioc_by_name.return_value = mock_ioc_record - mock_tag_objects.get_or_create.return_value = (Mock(), True) + mock_add_tags.return_value = 1 rfi_ioc = self._create_mock_ioc("evil.com", ioc_type="domain") self.strategy.ioc_processor.add_ioc = Mock(return_value=rfi_ioc) From 9d3e466f6b4bd5b8e317380db56b8203c3746f76 Mon Sep 17 00:00:00 2001 From: R1sh0bh-1 Date: Thu, 26 Mar 2026 18:34:14 +0530 Subject: [PATCH 092/109] Support for custom labels/descriptions in the Sensor model.Closes #1060 (#1086) * feat: add support for custom labels in Sensor model, API, and dashboard #1060 * perf: add prefetch_related(sensors) to avoid N+1 queries during IOC serialization * chore: resolve merge conflicts and renumber sensor label migration to 0046 --- api/serializers.py | 11 +++- .../components/dashboard/EnrichmentLookup.jsx | 19 +++++++ .../src/components/feeds/tableColumns.jsx | 17 ++++++ .../EnrichmentLookup.integration.test.jsx | 54 +++++++++++++++++++ .../components/feeds/TableColumns.test.jsx | 28 ++++++++++ greedybear/admin.py | 7 +-- greedybear/migrations/0046_sensor_label.py | 23 ++++++++ greedybear/models.py | 8 ++- tests/__init__.py | 8 +-- tests/test_migrations.py | 17 ++++++ tests/test_models.py | 13 ++++- tests/test_sensor_repository.py | 5 ++ tests/test_serializers.py | 29 +++++++++- 13 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 greedybear/migrations/0046_sensor_label.py diff --git a/api/serializers.py b/api/serializers.py index 55c3754d..aba8d0b1 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -6,7 +6,7 @@ from rest_framework import serializers from greedybear.consts import REGEX_DOMAIN -from greedybear.models import IOC, GeneralHoneypot, Tag +from greedybear.models import IOC, GeneralHoneypot, Sensor, Tag from greedybear.utils import is_ip_address logger = logging.getLogger(__name__) @@ -26,9 +26,16 @@ class Meta: fields = ["key", "value", "source"] +class SensorSerializer(serializers.ModelSerializer): + class Meta: + model = Sensor + fields = ["address", "label"] + + class IOCSerializer(serializers.ModelSerializer): general_honeypot = GeneralHoneypotSerializer(many=True, read_only=True) tags = TagSerializer(many=True, read_only=True) + sensors = SensorSerializer(many=True, read_only=True) class Meta: model = IOC @@ -56,7 +63,7 @@ def validate(self, data): raise serializers.ValidationError("Observable is not a valid IP address or domain") try: - required_object = IOC.objects.prefetch_related("tags").get(name=observable) + required_object = IOC.objects.prefetch_related("tags", "sensors").get(name=observable) data["found"] = True data["ioc"] = required_object except IOC.DoesNotExist: diff --git a/frontend/src/components/dashboard/EnrichmentLookup.jsx b/frontend/src/components/dashboard/EnrichmentLookup.jsx index f2f03f69..f3370a33 100644 --- a/frontend/src/components/dashboard/EnrichmentLookup.jsx +++ b/frontend/src/components/dashboard/EnrichmentLookup.jsx @@ -286,6 +286,25 @@ export default function EnrichmentLookup() { )} + {result.ioc.sensors && result.ioc.sensors.length > 0 && ( + + + Sensors: +
+ {result.ioc.sensors.map((sensor, idx) => ( +
+ {sensor.address} + {sensor.label && ( + +  ({sensor.label}) + + )} +
+ ))} +
+ +
+ )} {result.ioc.recurrence_probability !== undefined && ( diff --git a/frontend/src/components/feeds/tableColumns.jsx b/frontend/src/components/feeds/tableColumns.jsx index a7de0078..7ad29d23 100644 --- a/frontend/src/components/feeds/tableColumns.jsx +++ b/frontend/src/components/feeds/tableColumns.jsx @@ -119,6 +119,23 @@ const feedsTableColumns = [
ASN: {asn ?? "-"}
Reputation: {ip_reputation || "-"}
Country: {attacker_country || "-"}
+
+
Sensors
+ {row.original.sensors?.length > 0 ? ( + row.original.sensors.map((sensor, idx) => ( +
+ {sensor.address} + {sensor.label && ( + + {" "} + ({sensor.label}) + + )} +
+ )) + ) : ( +
-
+ )}
diff --git a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx index 1a357b3d..5fcd13b1 100644 --- a/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx +++ b/frontend/tests/components/dashboard/EnrichmentLookup.integration.test.jsx @@ -340,4 +340,58 @@ describe("Enrichment Lookup Integration Tests", () => { ).toBeInTheDocument(); }); }); + + test("look up an IP with authentication - sensors display scenario", async () => { + const user = userEvent.setup(); + + // Mock authenticated state + mockUseAuthStore.mockImplementation((selector) => + selector({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }), + ); + + // Mock successful API response with sensors data + const mockIocData = { + name: "8.8.8.8", + type: "ip", + attack_count: 10, + interaction_count: 20, + login_attempts: 5, + first_seen: "2024-01-01", + last_seen: "2024-01-15", + scanner: true, + payload_request: false, + sensors: [ + { address: "10.0.0.1", label: "AWS-Prod" }, + { address: "10.0.0.2", label: "" }, + ], + }; + + axios.get.mockResolvedValue({ + data: { + found: true, + query: "8.8.8.8", + ioc: mockIocData, + }, + }); + + render( + + + , + ); + + const inputElement = screen.getByLabelText("IP Address or Domain:"); + const submitButton = screen.getByRole("button", { name: /Search/i }); + + await user.type(inputElement, "8.8.8.8"); + await user.click(submitButton); + + // Verify sensors are displayed + await waitFor(() => { + expect(screen.getByText(/Sensors:/i)).toBeInTheDocument(); + expect(screen.getByText(/10\.0\.0\.1/i)).toBeInTheDocument(); + expect(screen.getByText(/\(AWS-Prod\)/i)).toBeInTheDocument(); + expect(screen.getByText(/10\.0\.0\.2/i)).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/tests/components/feeds/TableColumns.test.jsx b/frontend/tests/components/feeds/TableColumns.test.jsx index d5032fae..81aa0fa8 100644 --- a/frontend/tests/components/feeds/TableColumns.test.jsx +++ b/frontend/tests/components/feeds/TableColumns.test.jsx @@ -155,4 +155,32 @@ describe("Feeds table details popover", () => { expect(await screen.findByText(/Country:\s*-/i)).toBeInTheDocument(); }); + + test("shows sensors in popover details when sensors are provided", async () => { + const user = userEvent.setup(); + const detailsColumn = feedsTableColumns.find( + (column) => column.accessor === "details", + ); + + const row = { + id: "3", + original: { + sensors: [ + { address: "10.0.0.1", label: "AWS-West" }, + { address: "10.0.0.2", label: "" }, + ], + }, + }; + + const DetailsCell = detailsColumn.Cell; + render(); + + const detailsButton = screen.getByLabelText(/view details/i); + await user.click(detailsButton); + + expect(await screen.findByText(/Sensors/i)).toBeInTheDocument(); + expect(await screen.findByText(/10\.0\.0\.1/i)).toBeInTheDocument(); + expect(await screen.findByText(/\(AWS-West\)/i)).toBeInTheDocument(); + expect(await screen.findByText(/10\.0\.0\.2/i)).toBeInTheDocument(); + }); }); diff --git a/greedybear/admin.py b/greedybear/admin.py index 0ac18f17..2736b1d8 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -32,9 +32,10 @@ class TorExitNodeModelAdmin(admin.ModelAdmin): @admin.register(Sensor) class SensorsModelAdmin(admin.ModelAdmin): - list_display = ["id", "address"] - search_fields = ["address"] - search_help_text = ["search for the sensor IP address"] + list_display = ["id", "address", "country", "label"] + list_editable = ["label"] + search_fields = ["address", "label"] + search_help_text = ["search for the sensor IP address or label"] @admin.register(Statistics) diff --git a/greedybear/migrations/0046_sensor_label.py b/greedybear/migrations/0046_sensor_label.py new file mode 100644 index 00000000..ac47f27d --- /dev/null +++ b/greedybear/migrations/0046_sensor_label.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-03-17 17:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("greedybear", "0045_credential_protocol"), + ] + + operations = [ + migrations.AddField( + model_name="sensor", + name="label", + field=models.CharField( + blank=True, + default="", + help_text="Optional human-readable label to identify this sensor (e.g. 'home-pi', 'cloud-aws-eu').", + max_length=128, + ), + ), + ] \ No newline at end of file diff --git a/greedybear/models.py b/greedybear/models.py index c7a98d8f..63bc3fff 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -26,9 +26,15 @@ class Sensor(models.Model): blank=True, default="", ) + label = models.CharField( + max_length=128, + blank=True, + default="", + help_text="Optional human-readable label to identify this sensor (e.g. 'home-pi', 'cloud-aws-eu').", + ) def __str__(self): - return self.address + return f"{self.address} ({self.label})" if self.label else self.address class GeneralHoneypot(models.Model): diff --git a/tests/__init__.py b/tests/__init__.py index 674d8254..886a055e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -39,7 +39,7 @@ def setUpTestData(cls): type=IocType.IP.value, first_seen=cls.current_time, last_seen=cls.current_time, - days_seen=[cls.current_time], + days_seen=[cls.current_time.date()], number_of_days_seen=1, attack_count=1, interaction_count=1, @@ -60,7 +60,7 @@ def setUpTestData(cls): type=IocType.IP.value, first_seen=cls.current_time, last_seen=cls.current_time, - days_seen=[cls.current_time], + days_seen=[cls.current_time.date()], number_of_days_seen=1, attack_count=1, interaction_count=1, @@ -81,7 +81,7 @@ def setUpTestData(cls): type=IocType.IP.value, first_seen=cls.current_time, last_seen=cls.current_time, - days_seen=[cls.current_time], + days_seen=[cls.current_time.date()], number_of_days_seen=1, attack_count=1, interaction_count=1, @@ -102,7 +102,7 @@ def setUpTestData(cls): type=IocType.DOMAIN.value, first_seen=cls.current_time, last_seen=cls.current_time, - days_seen=[cls.current_time], + days_seen=[cls.current_time.date()], number_of_days_seen=1, attack_count=1, interaction_count=1, diff --git a/tests/test_migrations.py b/tests/test_migrations.py index d8112cbf..8d5b4fc7 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -244,3 +244,20 @@ def test_default_protocol_set_and_uniqueness_includes_protocol(self): Credential.objects.create(username="root", password="root", protocol="ssh") Credential.objects.create(username="root", password="root", protocol="ftp") self.assertEqual(Credential.objects.filter(username="root", password="root").count(), 3) + + +@tag("migration") +class TestSensorLabelMigration(MigrationTestCase): + """Tests the addition of the label field to the Sensor model.""" + + migrate_from = "0045_credential_protocol" + migrate_to = "0046_sensor_label" + + def test_existing_sensors_get_empty_label(self): + Sensor = self.old_state.apps.get_model(self.app_name, "Sensor") + Sensor.objects.create(address="10.0.0.1") + + new_state = self.apply_tested_migration() + sensor_new = new_state.apps.get_model(self.app_name, "Sensor") + migrated = sensor_new.objects.get(address="10.0.0.1") + self.assertEqual(migrated.label, "") diff --git a/tests/test_models.py b/tests/test_models.py index cc8f9f07..18be2556 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,7 @@ from django.db import IntegrityError from greedybear.enums import IpReputation -from greedybear.models import Credential, IocType, Statistics, Tag, ViewType +from greedybear.models import Credential, IocType, Sensor, Statistics, Tag, ViewType from . import CustomTestCase @@ -12,7 +12,7 @@ def test_ioc_model(self): self.assertEqual(self.ioc.type, IocType.IP.value) self.assertEqual(self.ioc.first_seen, self.current_time) self.assertEqual(self.ioc.last_seen, self.current_time) - self.assertEqual(self.ioc.days_seen, [self.current_time]) + self.assertEqual(self.ioc.days_seen, [self.current_time.date()]) self.assertEqual(self.ioc.number_of_days_seen, 1) self.assertEqual(self.ioc.attack_count, 1) self.assertEqual(self.ioc.interaction_count, 1) @@ -52,6 +52,15 @@ def test_cowrie_session_model(self): self.assertEqual(self.cowrie_session.source.name, "140.246.171.141") self.assertEqual(self.cowrie_session.commands.commands, self.cmd_seq) + def test_sensor_model(self): + sensor_no_label = Sensor.objects.create(address="10.0.0.1") + self.assertEqual(sensor_no_label.label, "") + self.assertEqual(str(sensor_no_label), "10.0.0.1") + + sensor_with_label = Sensor.objects.create(address="10.0.0.2", label="home-pi") + self.assertEqual(sensor_with_label.label, "home-pi") + self.assertEqual(str(sensor_with_label), "10.0.0.2 (home-pi)") + def test_statistics_model(self): self.statistic = Statistics.objects.create( source="140.246.171.141", diff --git a/tests/test_sensor_repository.py b/tests/test_sensor_repository.py index ad46666f..378fc7ec 100644 --- a/tests/test_sensor_repository.py +++ b/tests/test_sensor_repository.py @@ -59,6 +59,11 @@ def test_get_or_create_sensor_accepts_valid_ipv4(self): self.assertIsNotNone(result) self.assertIsInstance(result, Sensor) + def test_get_or_create_sensor_has_empty_label(self): + """get_or_create_sensor should create a sensor with an empty label.""" + result = self.repo.get_or_create_sensor("192.168.1.10") + self.assertEqual(result.label, "") + def test_update_country_sets_country(self): """update_country sets the Sensor's country if different.""" sensor = Sensor.objects.create(address="1.2.3.4", country="") diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 5156bdcd..6e78cdc5 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -3,10 +3,15 @@ from rest_framework.serializers import ValidationError -from api.serializers import FeedsRequestSerializer, FeedsResponseSerializer, parse_feed_types +from api.serializers import ( + FeedsRequestSerializer, + FeedsResponseSerializer, + IOCSerializer, + parse_feed_types, +) from greedybear.consts import PAYLOAD_REQUEST, SCANNER from greedybear.enums import IpReputation -from greedybear.models import IOC, GeneralHoneypot +from greedybear.models import IOC, GeneralHoneypot, Sensor from tests import CustomTestCase @@ -289,3 +294,23 @@ def test_invalid_fields(self): self.assertIn("login_attempts", serializer.errors) self.assertIn("recurrence_probability", serializer.errors) self.assertIn("expected_interactions", serializer.errors) + + +class IOCSerializerTestCase(CustomTestCase): + def test_ioc_serializer_with_sensors(self): + s1 = Sensor.objects.create(address="10.0.0.1", label="home-pi") + s2 = Sensor.objects.create(address="10.0.0.2") + + self.ioc.sensors.add(s1, s2) + + serializer = IOCSerializer(self.ioc) + data = serializer.data + + self.assertIn("sensors", data) + self.assertEqual(len(data["sensors"]), 2) + + sensors_data = sorted(data["sensors"], key=lambda x: x["address"]) + self.assertEqual(sensors_data[0]["address"], "10.0.0.1") + self.assertEqual(sensors_data[0]["label"], "home-pi") + self.assertEqual(sensors_data[1]["address"], "10.0.0.2") + self.assertEqual(sensors_data[1]["label"], "") From 83c5ed769fbe4fcbe5c0081117351d5c2d06622d Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:38:04 +0100 Subject: [PATCH 093/109] Fix blank page caused by upgrade to vite 8. Closes #1126 (#1127) * change all react-use/lib/ imports to react-use/esm/ * fix test * use top-level imports --- frontend/src/components/auth/Register.jsx | 2 +- frontend/src/components/auth/ResetPassword.jsx | 2 +- frontend/src/components/me/changepassword/ChangePassword.jsx | 2 +- frontend/src/wrappers/ifAuthRedirectGuard.jsx | 2 +- frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/auth/Register.jsx b/frontend/src/components/auth/Register.jsx index 899f7bd9..7c33fbdb 100644 --- a/frontend/src/components/auth/Register.jsx +++ b/frontend/src/components/auth/Register.jsx @@ -12,7 +12,7 @@ import { InputGroupText, } from "reactstrap"; import { Form, Formik } from "formik"; -import useTitle from "react-use/lib/useTitle"; +import { useTitle } from "react-use"; import { ContentSection, Select } from "@certego/certego-ui"; diff --git a/frontend/src/components/auth/ResetPassword.jsx b/frontend/src/components/auth/ResetPassword.jsx index c565180c..daae2044 100644 --- a/frontend/src/components/auth/ResetPassword.jsx +++ b/frontend/src/components/auth/ResetPassword.jsx @@ -2,7 +2,7 @@ import React from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { FormGroup, Label, Col, Input, Button, Spinner } from "reactstrap"; import { Form, Formik } from "formik"; -import useTitle from "react-use/lib/useTitle"; +import { useTitle } from "react-use"; import { ContentSection } from "@certego/certego-ui"; diff --git a/frontend/src/components/me/changepassword/ChangePassword.jsx b/frontend/src/components/me/changepassword/ChangePassword.jsx index 8ddf41b9..ef0e60f0 100644 --- a/frontend/src/components/me/changepassword/ChangePassword.jsx +++ b/frontend/src/components/me/changepassword/ChangePassword.jsx @@ -8,7 +8,7 @@ import { Button, } from "reactstrap"; import { Form, Formik } from "formik"; -import useTitle from "react-use/lib/useTitle"; +import { useTitle } from "react-use"; import { ContentSection } from "@certego/certego-ui"; diff --git a/frontend/src/wrappers/ifAuthRedirectGuard.jsx b/frontend/src/wrappers/ifAuthRedirectGuard.jsx index a20fad07..2863d17d 100644 --- a/frontend/src/wrappers/ifAuthRedirectGuard.jsx +++ b/frontend/src/wrappers/ifAuthRedirectGuard.jsx @@ -1,7 +1,7 @@ import React from "react"; import PropTypes from "prop-types"; import { Navigate } from "react-router-dom"; -import useSearchParam from "react-use/lib/useSearchParam"; +import { useSearchParam } from "react-use"; import { useAuthStore } from "../stores"; import { AUTHENTICATION_STATUSES } from "../constants"; diff --git a/frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx b/frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx index 05e22dec..fe1c9b1f 100644 --- a/frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx +++ b/frontend/tests/wrapper/IfAuthRedirectGuard.test.jsx @@ -11,8 +11,8 @@ vi.mock("../../src/stores", () => ({ })); const mockUseSearchParam = vi.fn(); -vi.mock("react-use/lib/useSearchParam", () => ({ - default: () => mockUseSearchParam(), +vi.mock("react-use", () => ({ + useSearchParam: () => mockUseSearchParam(), })); describe("IfAuthRedirectGuard", () => { From 2916d04a2fe5fa860985d588c576b25bad436828 Mon Sep 17 00:00:00 2001 From: Varun chauhan <115783538+chauhan-varun@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:33:11 +0530 Subject: [PATCH 094/109] Fix Zombie Sessions by handling 401/403. Closes #1072 (#1123) * feat: Add global Axios interceptor to reset authentication state on 401/403 responses and centralize auth state clearing with a new `reset` function. * style: Reformat the `INITIAL_USER` object for improved readability. * fix: refine interceptor to handle 403 as role sync instead of logout --- frontend/src/index.jsx | 22 ++++++++++++++++ frontend/src/stores/useAuthStore.jsx | 26 +++++++++++++------ frontend/tests/stores/useAuthStore.test.jsx | 28 ++++++++++++++++++--- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 2ede0593..8eda038c 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -2,7 +2,29 @@ import "./styles/App.scss"; import React from "react"; import ReactDOM from "react-dom"; +import axios from "axios"; import App from "./App"; +import useAuthStore from "./stores/useAuthStore"; + +// axios interceptor to handle session expiration (401) or role sync (403) +axios.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + const { status, config } = error.response; + // if 401, session is expired -> logout + if (status === 401) { + useAuthStore.getState().reset(); + } + // if 403, permission denied -> refresh roles (unless it's already the auth check) + else if (status === 403 && !config._isRetry) { + config._isRetry = true; + useAuthStore.getState().checkAuthentication(); + } + } + return Promise.reject(error); + }, +); function noop() {} diff --git a/frontend/src/stores/useAuthStore.jsx b/frontend/src/stores/useAuthStore.jsx index d3b434ef..80e5c0b2 100644 --- a/frontend/src/stores/useAuthStore.jsx +++ b/frontend/src/stores/useAuthStore.jsx @@ -12,11 +12,25 @@ import { } from "../constants/api"; import { AUTHENTICATION_STATUSES } from "../constants"; +const INITIAL_USER = { + full_name: "", + first_name: "", + last_name: "", + email: "", +}; + // hook/ store see: https://github.com/pmndrs/zustand const useAuthStore = create((set, get) => ({ - user: { full_name: "", first_name: "", last_name: "", email: "" }, + user: INITIAL_USER, isSuperuser: false, isAuthenticated: AUTHENTICATION_STATUSES.FALSE, + reset: () => { + set({ + isAuthenticated: AUTHENTICATION_STATUSES.FALSE, + user: INITIAL_USER, + isSuperuser: false, + }); + }, checkAuthentication: async () => { try { const resp = await axios.get(CHECK_AUTHENTICATION_URI, { @@ -31,8 +45,8 @@ const useAuthStore = create((set, get) => ({ set({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE }); } } catch (err) { - if (get().isAuthenticated === AUTHENTICATION_STATUSES.TRUE) { - set({ isAuthenticated: AUTHENTICATION_STATUSES.FALSE }); + if (get().isAuthenticated !== AUTHENTICATION_STATUSES.FALSE) { + get().reset(); } } }, @@ -73,11 +87,7 @@ const useAuthStore = create((set, get) => ({ logoutUser: async () => { set({ isAuthenticated: AUTHENTICATION_STATUSES.PENDING }); const onLogoutCb = () => { - set({ - isAuthenticated: AUTHENTICATION_STATUSES.FALSE, - user: { full_name: "", first_name: "", last_name: "", email: "" }, - isSuperuser: false, - }); + get().reset(); addToast("Logged out!", null, "info"); }; return axios diff --git a/frontend/tests/stores/useAuthStore.test.jsx b/frontend/tests/stores/useAuthStore.test.jsx index 18f38746..cafc524d 100644 --- a/frontend/tests/stores/useAuthStore.test.jsx +++ b/frontend/tests/stores/useAuthStore.test.jsx @@ -240,6 +240,23 @@ describe("useAuthStore", () => { }); }); + describe("reset", () => { + test("clears user, isSuperuser and sets isAuthenticated to FALSE", () => { + useAuthStore.setState({ + isAuthenticated: AUTHENTICATION_STATUSES.TRUE, + user: { email: "test@test.com" }, + isSuperuser: true, + }); + + useAuthStore.getState().reset(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(AUTHENTICATION_STATUSES.FALSE); + expect(state.isSuperuser).toBe(false); + expect(state.user).toEqual(INITIAL_USER); + }); + }); + describe("checkAuthentication", () => { test("sets auth TRUE and updates superuser", async () => { axios.get.mockResolvedValue({ @@ -257,9 +274,11 @@ describe("useAuthStore", () => { expect(useAuthStore.getState().isSuperuser).toBe(true); }); - test("sets auth FALSE when auth check fails while currently authenticated", async () => { + test("clears all auth data when auth check fails while currently authenticated", async () => { useAuthStore.setState({ isAuthenticated: AUTHENTICATION_STATUSES.TRUE, + user: { email: "test@test.com" }, + isSuperuser: true, }); axios.get.mockRejectedValue(new Error("auth check failed")); @@ -270,9 +289,10 @@ describe("useAuthStore", () => { expect(axios.get).toHaveBeenCalledWith(CHECK_AUTHENTICATION_URI, { headers: { "Content-Type": "application/json" }, }); - expect(useAuthStore.getState().isAuthenticated).toBe( - AUTHENTICATION_STATUSES.FALSE, - ); + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(AUTHENTICATION_STATUSES.FALSE); + expect(state.isSuperuser).toBe(false); + expect(state.user).toEqual(INITIAL_USER); }); test("keeps auth FALSE when auth check fails while already unauthenticated", async () => { From 74ac529d27f3ee305b99ccf4b54be386a5f550d3 Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:27:28 +0100 Subject: [PATCH 095/109] Migration to uv. Closes #1131 (#1027) * add .venv folder to .dockerignore * add early uv stage to Dockerfile to serve version number for frontend build * add requirements to pyproject.toml * read version number from pyproject.toml * add uv lock file * use uv to obtain version number in entrypoint * update release template * use uv in CI * override outdated dependency * update dependency-review action * make dependabot use uv * update dependencies * remove unused files * allow automatic minor updates and update dependencies * use uv in docker build * clean up old flake8 comment * re- add explicit linter exclusions in PR automation * bump uv in CI * use short version output * pin versions in pyproject.toml --- .dockerignore | 1 + .github/dependabot.yml | 4 +- .github/release_template.md | 2 +- .github/workflows/_python.yml | 129 +- .github/workflows/dependency_review.yml | 2 +- .github/workflows/pull_request_automation.yml | 2 - docker/.version | 1 - docker/Dockerfile | 36 +- docker/entrypoint_gunicorn.sh | 5 +- greedybear/settings.py | 5 +- pyproject.toml | 46 + requirements/dev-requirements.txt | 4 - requirements/project-requirements.txt | 26 - uv.lock | 1185 +++++++++++++++++ 14 files changed, 1276 insertions(+), 172 deletions(-) delete mode 100644 docker/.version delete mode 100644 requirements/dev-requirements.txt delete mode 100644 requirements/project-requirements.txt create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore index 55321011..a0326ff6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ .vscode .lgtm.yml __pycache__ +.venv/ venv/ **/build .env diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 283b8c27..a403536c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,8 @@ version: 2 updates: - - package-ecosystem: "pip" - directory: "/requirements" + - package-ecosystem: "uv" + directory: "/" schedule: interval: "weekly" day: "tuesday" diff --git a/.github/release_template.md b/.github/release_template.md index e724dd32..fc93c40b 100644 --- a/.github/release_template.md +++ b/.github/release_template.md @@ -1,6 +1,6 @@ # Checklist for creating a new release -- [ ] Change version number in `docker/.version` and in `.env_template` +- [ ] Change version number in `pyproject.toml` - [ ] Verify CI Tests - [ ] Verify that the PR is named with a correct version number like x.x.x - [ ] Merge the PR to the `main` branch. The release will be done automatically by the CI diff --git a/.github/workflows/_python.yml b/.github/workflows/_python.yml index e1b0221f..a7365c75 100644 --- a/.github/workflows/_python.yml +++ b/.github/workflows/_python.yml @@ -17,9 +17,9 @@ on: type: string required: true requirements_path: - description: Path to the requirements.txt file + description: Path to the requirements.txt file (deprecated, unused with uv) type: string - required: true + required: false project_dev_requirements_file: description: Path to an additional project dev requirements file type: string @@ -264,7 +264,7 @@ jobs: id: setup_python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python_version }} + python-version-file: "pyproject.toml" - name: Inject stuff to environment run: | @@ -312,128 +312,19 @@ jobs: with: apt_requirements_file_path: ${{ inputs.packages_path }} - - name: Create linter requirements file - uses: ./.github/actions/python_requirements/create_linter_requirements_file - with: - install_from: ${{ inputs.install_from }} - django_settings_module: ${{ inputs.django_settings_module }} - use_autoflake: ${{ inputs.use_autoflake }} - use_bandit: ${{ inputs.use_bandit }} - use_black: ${{ inputs.use_black }} - use_flake8: ${{ inputs.use_flake8 }} - use_isort: ${{ inputs.use_isort }} - use_pylint: ${{ inputs.use_pylint }} - use_ruff_formatter: ${{ inputs.use_ruff_formatter }} - use_ruff_linter: ${{ inputs.use_ruff_linter }} - - - name: Create dev requirements file - uses: ./.github/actions/python_requirements/create_dev_requirements_file - with: - install_from: ${{ inputs.install_from }} - project_dev_requirements_file: ${{ inputs.project_dev_requirements_file }} - - - name: Create docs requirements file - uses: ./.github/actions/python_requirements/create_docs_requirements_file + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - install_from: ${{ inputs.install_from }} - check_docs_directory: ${{ inputs.check_docs_directory }} - django_settings_module: ${{ inputs.django_settings_module }} + enable-cache: true + version: "0.11.1" - - name: Restore Python virtual environment related to PR event - id: restore_python_virtual_environment_pr - uses: ./.github/actions/python_requirements/restore_virtualenv/ - with: - requirements_paths: "${{ inputs.requirements_path }} requirements-linters.txt requirements-dev.txt requirements-docs.txt" - python_version: ${{ steps.setup_python.outputs.python-version }} - - - name: Restore Python virtual environment related to target branch - id: restore_python_virtual_environment_target_branch - if: steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' - uses: ./.github/actions/python_requirements/restore_virtualenv/ - with: - requirements_paths: ${{ inputs.requirements_path }} - git_reference: ${{ github.base_ref }} - python_version: ${{ steps.setup_python.outputs.python-version }} - - - name: Create Python virtual environment - if: > - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' && - steps.restore_python_virtual_environment_target_branch.outputs.cache-hit != 'true' - uses: ./.github/actions/python_requirements/create_virtualenv - - - name: Restore pip cache related to PR event - id: restore_pip_cache_pr - if: > - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' && - steps.restore_python_virtual_environment_target_branch.outputs.cache-hit != 'true' - uses: ./.github/actions/python_requirements/restore_pip_cache - - - name: Restore pip cache related to target branch - id: restore_pip_cache_target_branch - if: > - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' && - steps.restore_python_virtual_environment_target_branch.outputs.cache-hit != 'true' && - steps.restore_pip_cache_pr.outputs.cache-hit != 'true' - uses: ./.github/actions/python_requirements/restore_pip_cache - with: - git_reference: ${{ github.base_ref }} - - - name: Install project requirements - if: > - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' && - steps.restore_python_virtual_environment_target_branch.outputs.cache-hit != 'true' - run: pip install -r ${{ inputs.requirements_path }} - shell: bash - working-directory: ${{ inputs.install_from }} - - - name: Install other requirements - if: > - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' + - name: Install dependencies run: | - pip install -r requirements-dev.txt - pip install -r requirements-linters.txt - pip install -r requirements-docs.txt + uv sync --frozen --all-groups + echo "${{ github.workspace }}/${{ inputs.install_from }}/.venv/bin" >> $GITHUB_PATH shell: bash working-directory: ${{ inputs.install_from }} - - name: Check requirements licenses - if: > - inputs.check_requirements_licenses && - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' - id: license_check_report - continue-on-error: true - uses: pilosus/action-pip-license-checker@v2 - with: - requirements: ${{ inputs.install_from }}/${{ inputs.requirements_path }} - exclude: ${{ inputs.ignore_requirements_licenses_regex }} - headers: true - fail: 'StrongCopyleft,NetworkCopyleft,Error' - fails-only: true - - - name: Print wrong licenses - if: steps.license_check_report.outcome == 'failure' - run: | - echo "License check failed" - echo "====================" - echo "${{ steps.license_check_report.outputs.report }}" - echo "====================" - exit 1 - shell: bash - - - name: Save Python virtual environment related to PR event - if: > - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' - uses: ./.github/actions/python_requirements/save_virtualenv - with: - requirements_paths: "${{ inputs.requirements_path }} requirements-linters.txt requirements-dev.txt requirements-docs.txt" - python_version: ${{ steps.setup_python.outputs.python-version }} - - - name: Save pip cache related to PR event - if: > - steps.restore_python_virtual_environment_pr.outputs.cache-hit != 'true' && - steps.restore_pip_cache_pr.outputs.cache-hit != 'true' - uses: ./.github/actions/python_requirements/save_pip_cache - - name: Run linters uses: ./.github/actions/python_linter if: > diff --git a/.github/workflows/dependency_review.yml b/.github/workflows/dependency_review.yml index 8b6be3cc..2e7df738 100644 --- a/.github/workflows/dependency_review.yml +++ b/.github/workflows/dependency_review.yml @@ -13,4 +13,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@v3 - name: 'Dependency Review' - uses: actions/dependency-review-action@v3 \ No newline at end of file + uses: actions/dependency-review-action@v4 \ No newline at end of file diff --git a/.github/workflows/pull_request_automation.yml b/.github/workflows/pull_request_automation.yml index 000dde6b..61e170e2 100644 --- a/.github/workflows/pull_request_automation.yml +++ b/.github/workflows/pull_request_automation.yml @@ -62,8 +62,6 @@ jobs: use_ruff_formatter: true use_ruff_linter: true - requirements_path: requirements/project-requirements.txt - project_dev_requirements_file: requirements/dev-requirements.txt packages_path: packages.txt django_settings_module: greedybear.settings diff --git a/docker/.version b/docker/.version deleted file mode 100644 index db468102..00000000 --- a/docker/.version +++ /dev/null @@ -1 +0,0 @@ -VITE_GREEDYBEAR_VERSION="3.2.0" \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 0d1908b6..36775739 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,15 @@ +## ------------------------------- UV Stage ------------------------------ ## + +FROM python:3.13-slim-trixie AS uv + +COPY --from=ghcr.io/astral-sh/uv:0.11.1 /uv /bin/uv +COPY pyproject.toml . + +RUN echo "VITE_GREEDYBEAR_VERSION=\"$(uv version --short)\"" > .env.local + + ## ------------------------------- Frontend Build Stage ------------------------------ ## + FROM node:lts-alpine3.22 AS frontend-build WORKDIR /app @@ -9,21 +20,22 @@ RUN npm ci # copy source code and build COPY frontend/ . -COPY docker/.version .env.local +COPY --from=uv /.env.local . RUN VITE_BASE_URL=/static/reactapp/ npm run build ## ------------------------------- Production Stage ------------------------------ ## -FROM python:3.13-slim-trixie AS production +FROM uv AS production ENV DEBIAN_FRONTEND=noninteractive ENV PYTHONUNBUFFERED=1 ENV DJANGO_SETTINGS_MODULE=greedybear.settings -ENV PYTHONPATH=/opt/deploy/greedybear +ENV APP_ROOT=/opt/deploy/greedybear ENV LOG_PATH=/var/log/greedybear +ENV UV_PROJECT_ENVIRONMENT=/usr/local -WORKDIR $PYTHONPATH +WORKDIR $APP_ROOT # Install runtime dependencies # - libgomp1 is required for model training @@ -33,11 +45,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # Install python packages -COPY requirements/project-requirements.txt requirements/project-requirements.txt -RUN pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check -q -r requirements/project-requirements.txt +COPY pyproject.toml uv.lock ./ +RUN uv sync --no-dev --locked # Copy files -COPY . $PYTHONPATH +COPY . $APP_ROOT COPY --from=frontend-build /app/build /var/www/reactapp # separation is required to avoid to re-execute os installation in case of change of python requirements @@ -47,17 +59,19 @@ RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \ && touch ${LOG_PATH}/django/django_q.log ${LOG_PATH}/django/django_q_errors.log \ && touch ${LOG_PATH}/django/django_errors.log ${LOG_PATH}/django/elasticsearch.log \ && touch ${LOG_PATH}/django/authentication.log ${LOG_PATH}/django/authentication_errors.log \ - && mkdir -p ${PYTHONPATH}/mlmodels \ + && mkdir -p ${APP_ROOT}/mlmodels \ && usermod -u 2000 www-data \ - && chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${PYTHONPATH}/mlmodels/ \ + && chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${APP_ROOT}/mlmodels/ \ && rm -rf frontend/ + ## ------------------------------- Development Stage ------------------------------ ## FROM production AS development -# Install dev requirements -RUN pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check -q -r requirements/dev-requirements.txt +# Install dev dependencies +RUN uv sync --group dev --locked + ## ------------------------------- Default Stage ------------------------------ ## diff --git a/docker/entrypoint_gunicorn.sh b/docker/entrypoint_gunicorn.sh index d1f8b98b..1d71e4c4 100755 --- a/docker/entrypoint_gunicorn.sh +++ b/docker/entrypoint_gunicorn.sh @@ -24,11 +24,10 @@ mkdir -p /run/gunicorn chown -R 2000:82 /var/log/greedybear /run/gunicorn # Obtain the current GreedyBear version number -. /opt/deploy/greedybear/docker/.version -export VITE_GREEDYBEAR_VERSION +GREEDYBEAR_VERSION=$(uv version --short) echo "------------------------------" -echo "GreedyBear $VITE_GREEDYBEAR_VERSION" +echo "GreedyBear $GREEDYBEAR_VERSION" echo "DEBUG: $DEBUG" echo "DJANGO_TEST_SERVER: $DJANGO_TEST_SERVER" echo "------------------------------" diff --git a/greedybear/settings.py b/greedybear/settings.py index 0c0a320d..e1d9b60f 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -1,8 +1,8 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. -# flake8: noqa import logging import os +import tomllib from datetime import timedelta from django.core.management.utils import get_random_secret_key @@ -53,7 +53,8 @@ DEFAULT_SLACK_CHANNEL = os.environ.get("DEFAULT_SLACK_CHANNEL", "") NTFY_URL = os.environ.get("NTFY_URL", "") -VERSION = os.environ.get("VITE_GREEDYBEAR_VERSION", "") +with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f: + VERSION = tomllib.load(f)["project"]["version"] CSRF_COOKIE_SAMESITE = "Strict" CSRF_COOKIE_HTTPONLY = True diff --git a/pyproject.toml b/pyproject.toml index 79d5a937..a6d333bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,48 @@ +[project] +name = "greedybear" +version = "3.3.0" +requires-python = "==3.13.*" +dependencies = [ + # Django core + "Django==5.2.12", + "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", + "certego-saas==0.7.12", + # Server Gateway Interface + "gunicorn==25.2.0", + # Data stores + "elasticsearch==9.3.0", + "psycopg2-binary==2.9.11", + # ML / data science + "scikit-learn==1.8.0", + "pandas==3.0.1", + "numpy==2.4.3", + "joblib==1.5.3", + "datasketch==1.9.0", + # File Format Support + "feedparser==6.0.12", + "stix2==3.0.2", + # Utilities + "requests==2.33.0", + "slack-sdk==3.41.0", +] + +[dependency-groups] +dev = [ + "coverage==7.13.5", + "django-test-migrations==1.5.0", + "django-watchfiles==1.4.0", +] +lint = [ + "ruff==0.15.8", +] + +[tool.uv] +prerelease = "allow" +override-dependencies = ["markdown==3.10.2"] + [tool.ruff] extend = ".github/configurations/python_linters/.ruff.toml" diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt deleted file mode 100644 index 8619cdf7..00000000 --- a/requirements/dev-requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Development requirements -coverage>=7.3.2 -django-test-migrations>=1.5.0 -django-watchfiles>=1.4.0 diff --git a/requirements/project-requirements.txt b/requirements/project-requirements.txt deleted file mode 100644 index 9a0c0ad4..00000000 --- a/requirements/project-requirements.txt +++ /dev/null @@ -1,26 +0,0 @@ -django-q2==1.9.0 -croniter==6.2.2 - -# if you change this, update the documentation -elasticsearch==9.3.0 - -Django==5.2.12 -djangorestframework==3.17.1 -django-rest-email-auth==5.0.0 -django-ses==4.7.2 - -psycopg2-binary==2.9.11 - -certego-saas==0.7.11 -slack-sdk==3.41.0 - -gunicorn==25.1.0 - -joblib==1.5.3 -pandas==3.0.1 -scikit-learn==1.8.0 -numpy==2.4.3 -datasketch==1.9.0 - -feedparser==6.0.12 -stix2==3.0.2 \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..92a075a7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1185 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[options] +prerelease-mode = "allow" + +[manifest] +overrides = [{ name = "markdown", specifier = "==3.10.2" }] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5f/2cdf6f7aca3b20d3f316e9f505292e1f256a32089bd702034c29ebde6242/antlr4_python3_runtime-4.13.2.tar.gz", hash = "sha256:909b647e1d2fc2b70180ac586df3933e38919c85f98ccc656a96cd3f25ef3916", size = 117467, upload-time = "2024-08-03T19:00:12.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.76" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/13/33c8b8704d677fcaf5555ba8c6cc39468fc7b9a0c6b6c496e008cd5557fc/boto3-1.42.76.tar.gz", hash = "sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814", size = 112789, upload-time = "2026-03-25T19:33:25.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/dc/21b3dfb135125eb7e3a46b9aab0aede847726f239fc8f39474742a87ebb0/boto3-1.42.76-py3-none-any.whl", hash = "sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e", size = 140557, upload-time = "2026-03-25T19:33:23.289Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.76" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/62/a982acb81c5e0312f90f841b790abad65622c08aad356eed7008ea3d475b/botocore-1.42.76.tar.gz", hash = "sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874", size = 15021811, upload-time = "2026-03-25T19:33:12.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/63/7429d68876b7718ab5c4b8a44414de7907f5ba6bb27ccfad384df14fb277/botocore-1.42.76-py3-none-any.whl", hash = "sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607", size = 14697736, upload-time = "2026-03-25T19:33:07.573Z" }, +] + +[[package]] +name = "certego-saas" +version = "0.7.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-cache-memoize" }, + { name = "django-email-utils" }, + { name = "django-filter" }, + { name = "django-rest-durin" }, + { name = "django-user-agents" }, + { name = "djangorestframework" }, + { name = "djangorestframework-filters" }, + { name = "drf-flex-fields" }, + { name = "drf-recaptcha" }, + { name = "drf-spectacular" }, + { name = "elasticsearch" }, + { name = "markdown" }, + { name = "python-twitter" }, + { name = "slack-sdk" }, + { name = "stripe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/45/bc73e8264d29dc07aed7bc02a568f485f32c6c0cf2b7c6134e86c062518d/certego_saas-0.7.12.tar.gz", hash = "sha256:66d9e02325832cd752c234f13db11670cad149f0420f44e32166afb832ffbba4", size = 63360, upload-time = "2026-03-25T12:04:49.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/3c/b72ffa274677fd48c7e130e0bc022c1f6f4a173790038336088ddf22c27a/certego_saas-0.7.12-py3-none-any.whl", hash = "sha256:7336372f1a2c5f7e01d98886e7d2285843728b88a4fa5e59899fbbde3f46e17f", size = 92162, upload-time = "2026-03-25T12:04:47.96Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +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" } +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" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "croniter" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, +] + +[[package]] +name = "datasketch" +version = "1.9.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" } +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" }, +] + +[[package]] +name = "django" +version = "5.2.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" }, +] + +[[package]] +name = "django-cache-memoize" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/81/8e07e815118a5afbb9a10e766d9338751cc11ca462dea7f8a509a8fa22c5/django_cache_memoize-0.2.1.tar.gz", hash = "sha256:025ff5d941420247b83452bb183bde172dde7def95501fca829adf8ea01b2b7b", size = 21865, upload-time = "2024-12-18T18:08:52.929Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/b4/81550fd4e8ae9e2dd017b761d73fa4aa22dccab13076622ea5a3ec76e7d4/django_cache_memoize-0.2.1-py3-none-any.whl", hash = "sha256:07929d063a03557013875d453a13edc8e3359d3ba8a568b64ac3288578ed8be3", size = 14797, upload-time = "2024-12-18T18:08:51.199Z" }, +] + +[[package]] +name = "django-email-utils" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/65/40a60d55aa2cc93dd5a806bcad5486bd8fe672a96c0a6b599dd772ed4f83/django-email-utils-1.0.0.tar.gz", hash = "sha256:449c07ea11f5fc0e882d20a82c408a79064c55355cc81ae1ac3278752e891d5f", size = 4919, upload-time = "2019-09-12T00:42:29.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/51/87c302a8b33802be62d156f39ee4e20af23aa48c9bb21fbca2f8acbb4be4/django_email_utils-1.0.0-py2.py3-none-any.whl", hash = "sha256:cc5b5627c25acd02898bafb25c9ed98e61d4755c45952cda4caeff4d1900068c", size = 5320, upload-time = "2019-09-12T00:42:27.656Z" }, +] + +[[package]] +name = "django-filter" +version = "25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, +] + +[[package]] +name = "django-ipware" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-ipware" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/64/c7e4791edf01ba483cce444770b3e6a930ba12195ba1eeb37b5bf6dce8a8/django-ipware-7.0.1.tar.gz", hash = "sha256:d9ec43d2bf7cdf216fed8d494a084deb5761a54860a53b2e74346a4f384cff47", size = 6827, upload-time = "2024-04-19T20:02:49.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/33/bf539925b102d68200da5b1d3eacb8aa5d5d9a065972e8b8724d0d53bb0d/django_ipware-7.0.1-py2.py3-none-any.whl", hash = "sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709", size = 6425, upload-time = "2024-04-19T20:02:47.469Z" }, +] + +[[package]] +name = "django-picklefield" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/03/13114bccbd1ec8c026ac1ff33dae75ae6c6a5632e4769ee9cda283b9f57e/django_picklefield-3.4.0.tar.gz", hash = "sha256:3a1f740536c0e60d0dba43aa89ccdbe86760d4c3f8ec47799eae122baa741d0a", size = 12555, upload-time = "2025-11-27T03:11:53.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/b7/139eb1419ca7b27fd714925b8d0eed6efb592479dcf2155fed6c0c87c956/django_picklefield-3.4.0-py3-none-any.whl", hash = "sha256:929bcfbae5b48bd22a52bc04521fdfdd152eee36abb9f20228f9480f9df65f45", size = 10031, upload-time = "2025-11-27T03:11:51.937Z" }, +] + +[[package]] +name = "django-q2" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-picklefield" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/e6/21375bed54a4be1339f6ee31e4173d361d457dbe91db7bff130b52566126/django_q2-1.9.0.tar.gz", hash = "sha256:ef7facca96fae9c11ddf2c5252d3817975c7a9a6d989fa0d65487d8823d57799", size = 77218, upload-time = "2025-12-04T22:11:29.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/b7/8282f9815fc9df3187d9303a6f54e0388e02742255dee1fed7b4019a03ae/django_q2-1.9.0-py3-none-any.whl", hash = "sha256:4eded27644b0ffb291839c9f9c12fea6c0dec63ebd891fa6881b0b446098a49d", size = 89615, upload-time = "2025-12-04T22:11:28.079Z" }, +] + +[[package]] +name = "django-rest-durin" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "humanize" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/d1/0a8b40474a255a9aafe2e0bb507377d40c21d308beab654ede3d84a596bf/django-rest-durin-1.0.0.tar.gz", hash = "sha256:24ddb96f39666b47a0e024c1ec15ab5897db9800a9e777fc0963e38ad8d6fd24", size = 15398, upload-time = "2022-01-20T12:47:47.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/42/89586571b1a70c2ab8c30a04d013c7e2088f5ba9b43535bacb7adf4c05fe/django_rest_durin-1.0.0-py3-none-any.whl", hash = "sha256:4a299756f79ade28f9a81e0df51ee1fea0e28e834aaa2fa8e58a08e60c363856", size = 19279, upload-time = "2022-01-20T12:47:46.049Z" }, +] + +[[package]] +name = "django-rest-email-auth" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-email-utils" }, + { name = "djangorestframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/5a/7e90ab81fddc3b34e2d30d35b0ec74d68b46eeeb842db925ad199c138250/django_rest_email_auth-5.0.0.tar.gz", hash = "sha256:7b3914eff256362ad5634ad2263faef7a8a363e5d1dbae249ba33910956db019", size = 21299, upload-time = "2025-09-20T14:59:47.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/e7/cd1c2385eeb90687c6d01d28f174864dcf191b065aa5fd5c20de92bd4a13/django_rest_email_auth-5.0.0-py3-none-any.whl", hash = "sha256:ae375d1f33c5944932056d82098ecbf819548543f236d94b20ba3a3ee8949cd3", size = 42777, upload-time = "2025-09-20T14:59:46.537Z" }, +] + +[[package]] +name = "django-ses" +version = "4.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/25/25838da8e213c9f125b26a25360f0bb8ac57f07c24977451f3e7a0d63ddd/django_ses-4.7.2.tar.gz", hash = "sha256:a36f2af0e4ce060bf36053ed4c94feac1703ea3351e677c6f6421abd01433a35", size = 71828, upload-time = "2026-02-20T19:22:35.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/f2/15d4bd54bd01e68e8a116e0b66243c91cb62a3fa0d780a39d2af6654e8ae/django_ses-4.7.2-py3-none-any.whl", hash = "sha256:f3db567fb6f43c01d7d890f5c991e1ebbfa48220de0be24d497ba6332004abcb", size = 37796, upload-time = "2026-02-20T19:22:32.819Z" }, +] + +[[package]] +name = "django-test-migrations" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ed/7fc6f8e89d83565fc4acb93ae0a2387d885ac83cda445cb6c570f302bf55/django_test_migrations-1.5.0.tar.gz", hash = "sha256:1cbff04b1e82c5564a6f635284907b381cc11a2ff883adff46776d9126824f07", size = 20143, upload-time = "2025-04-18T10:15:38.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/fe/38789c69f71adff9156bda7542d8fd05fcde1a109cf67bd7a1a139f8199f/django_test_migrations-1.5.0-py3-none-any.whl", hash = "sha256:96a08f085fc8bfaa53d44618341d82a2d22fd194c821cd81b147b66f0bec0da8", size = 25099, upload-time = "2025-04-18T10:15:37.16Z" }, +] + +[[package]] +name = "django-user-agents" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "user-agents" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/f2/dd96cc880d7549cc9f67c8b1ad8e6695f9731658fdf8aa476f0bcb9c89c7/django-user_agents-0.4.0.tar.gz", hash = "sha256:cda8ae2146cee30e6867a07943f56ecc570b4391d725ab5309901a8b3e4a3514", size = 8501, upload-time = "2019-06-22T13:33:50.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a3/31b2728702f13328cc65d6c8ed652ad8125a9a71489e445c11f188b39f79/django_user_agents-0.4.0-py3-none-any.whl", hash = "sha256:cd9d9f7158b23c5237b2dacb0bc4fffdf77fefe1d2633b5814d3874288ebdb5d", size = 8616, upload-time = "2019-06-22T13:33:48.259Z" }, +] + +[[package]] +name = "django-watchfiles" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/89/51b82766a1c937dea9fb92f0fb7666919d120379fc26dc84ab504e35f37e/django_watchfiles-1.4.0.tar.gz", hash = "sha256:88e253e8e8427834eac467923d334c35010279b3e9ceb5bb91014be83975b11b", size = 8647, upload-time = "2025-09-22T16:34:04.634Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/85/3d31f347e1a3eef9be381792df1994ff25e4db4659ab40a5cc3f814e3a1a/django_watchfiles-1.4.0-py3-none-any.whl", hash = "sha256:40cb3180987c63629b0681ab228b65afd68e0f783388cc72a45620378213bb48", size = 6766, upload-time = "2025-09-22T16:34:03.193Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, +] + +[[package]] +name = "djangorestframework-filters" +version = "1.0.0.dev2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django-filter" }, + { name = "djangorestframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ca/48febcd71a00685435b4780a498c169bb08f0a71f83b56bfd147de3513d8/djangorestframework-filters-1.0.0.dev2.tar.gz", hash = "sha256:ef84527e3427434d54228825b53a35098c8633c1e77b71d06b79597b749ea3f2", size = 22341, upload-time = "2020-08-09T05:32:23.844Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/34/98f8d16743fb3037238c42cf9bb7e42b6e958f297c416fbddbe2473261f2/djangorestframework_filters-1.0.0.dev2-py3-none-any.whl", hash = "sha256:7369998968d656707e013da8c0c3ef1f858b99c4caaa8e9ea40861e5d6ddecff", size = 21755, upload-time = "2020-08-09T05:32:21.841Z" }, +] + +[[package]] +name = "drf-flex-fields" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/af/e4819b8ffe4f00694e79810a33c2afc46ee6df98c0bc045a65c5b275678c/drf-flex-fields-1.0.2.tar.gz", hash = "sha256:48139eeff0b1232fc05a9f353c3c2b570b225985043dedda6ab0d5e8b7a1d7af", size = 30476, upload-time = "2023-03-11T13:30:29.787Z" } + +[[package]] +name = "drf-recaptcha" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-ipware" }, + { name = "djangorestframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/f6/d155de397cee001be425f9d6790b83acbb9d54bd18e984c53b2d445c8cda/drf_recaptcha-4.0.3.tar.gz", hash = "sha256:c39a406d7a22134c23438a7feef93c2d18ea2c9e3072da6de91aa6ce7d81cc14", size = 7805, upload-time = "2025-08-22T13:51:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/ac/1680d655f1b77e0bbbd617ae6f13bbaf75c8b5aec048dfa6576b2615b0e8/drf_recaptcha-4.0.3-py3-none-any.whl", hash = "sha256:eb0762c22bc1d4b877d4ff277de3baa1d7581a5126160d3c2717cba6d692b0ac", size = 9358, upload-time = "2025-08-22T13:51:18.022Z" }, +] + +[[package]] +name = "drf-spectacular" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "inflection" }, + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" }, +] + +[[package]] +name = "elastic-transport" +version = "9.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "sniffio" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/0a/a92140b666afdcb9862a16e4d80873b3c887c1b7e3f17e945fc3460edf1b/elastic_transport-9.2.1.tar.gz", hash = "sha256:97d9abd638ba8aa90faa4ca1bf1a18bde0fe2088fbc8757f2eb7b299f205773d", size = 77403, upload-time = "2025-12-23T11:54:12.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e6/a42b600ae8b808371f740381f6c32050cad93f870d36cc697b8b7006bf7c/elastic_transport-9.2.1-py3-none-any.whl", hash = "sha256:39e1a25e486af34ce7aa1bc9005d1c736f1b6fb04c9b64ea0604ded5a61fc1d4", size = 65327, upload-time = "2025-12-23T11:54:11.681Z" }, +] + +[[package]] +name = "elasticsearch" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "elastic-transport" }, + { name = "python-dateutil" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/15/283459c9299d412ffa2aaab69b082857631c519233f5491d6c567e3320ca/elasticsearch-9.3.0.tar.gz", hash = "sha256:f76e149c0a22d5ccbba58bdc30c9f51cf894231b359ef4fd7e839b558b59f856", size = 893538, upload-time = "2026-02-03T20:26:38.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/37/3a196f8918743f2104cb66b1f56218079ecac6e128c061de7df7f4faef02/elasticsearch-9.3.0-py3-none-any.whl", hash = "sha256:67bd2bb4f0800f58c2847d29cd57d6e7bf5bc273483b4f17421f93e75ba09f39", size = 979405, upload-time = "2026-02-03T20:26:34.552Z" }, +] + +[[package]] +name = "feedparser" +version = "6.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sgmllib3k" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] +name = "greedybear" +version = "3.3.0" +source = { virtual = "." } +dependencies = [ + { name = "certego-saas" }, + { name = "croniter" }, + { name = "datasketch" }, + { name = "django" }, + { name = "django-q2" }, + { name = "django-rest-email-auth" }, + { name = "django-ses" }, + { name = "djangorestframework" }, + { name = "elasticsearch" }, + { name = "feedparser" }, + { name = "gunicorn" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "psycopg2-binary" }, + { name = "requests" }, + { name = "scikit-learn" }, + { name = "slack-sdk" }, + { name = "stix2" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "django-test-migrations" }, + { name = "django-watchfiles" }, +] +lint = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "certego-saas", specifier = "==0.7.12" }, + { name = "croniter", specifier = "==6.2.2" }, + { name = "datasketch", specifier = "==1.9.0" }, + { name = "django", specifier = "==5.2.12" }, + { name = "django-q2", specifier = "==1.9.0" }, + { name = "django-rest-email-auth", specifier = "==5.0.0" }, + { name = "django-ses", specifier = "==4.7.2" }, + { name = "djangorestframework", specifier = "==3.17.1" }, + { name = "elasticsearch", specifier = "==9.3.0" }, + { name = "feedparser", specifier = "==6.0.12" }, + { name = "gunicorn", specifier = "==25.2.0" }, + { name = "joblib", specifier = "==1.5.3" }, + { name = "numpy", specifier = "==2.4.3" }, + { name = "pandas", specifier = "==3.0.1" }, + { name = "psycopg2-binary", specifier = "==2.9.11" }, + { name = "requests", specifier = "==2.33.0" }, + { name = "scikit-learn", specifier = "==1.8.0" }, + { name = "slack-sdk", specifier = "==3.41.0" }, + { name = "stix2", specifier = "==3.0.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { 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.8" }] + +[[package]] +name = "gunicorn" +version = "25.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/13/dd3f8e40ea3ee907a6cbf3d1f1f81afcc3ecd0087d313baabfe95372f15c/gunicorn-25.2.0.tar.gz", hash = "sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c", size = 632709, upload-time = "2026-03-24T22:49:54.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/53/fb024445837e02cd5cf989cf349bfac6f3f433c05184ea5d49c8ade751c6/gunicorn-25.2.0-py3-none-any.whl", hash = "sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc", size = 211659, upload-time = "2026-03-24T22:49:52.528Z" }, +] + +[[package]] +name = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +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" } +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" }, +] + +[[package]] +name = "inflection" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, + { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, + { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-ipware" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/60/da4426c3e9aee56f08b24091a9e85a0414260f928f97afd0013dfbd0332f/python_ipware-3.0.0.tar.gz", hash = "sha256:9117b1c4dddcb5d5ca49e6a9617de2fc66aec2ef35394563ac4eecabdf58c062", size = 16609, upload-time = "2024-04-19T20:00:58.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/bd/ccd7416fdb30f104ddf6cfd8ee9f699441c7d9880a26f9b3089438adee05/python_ipware-3.0.0-py3-none-any.whl", hash = "sha256:fc936e6e7ec9fcc107f9315df40658f468ac72f739482a707181742882e36b60", size = 10761, upload-time = "2024-04-19T20:00:57.171Z" }, +] + +[[package]] +name = "python-twitter" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "future" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/63/5941b988f1a119953b046ae820bc443ada3c9e0538a80d67f3938f9418f1/python-twitter-3.5.tar.gz", hash = "sha256:45855742f1095aa0c8c57b2983eee3b6b7f527462b50a2fa8437a8b398544d90", size = 87701, upload-time = "2018-11-03T12:32:08.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/a9/2eb36853d8ca49a70482e2332aa5082e09b3180391671101b1612e3aeaf1/python_twitter-3.5-py2.py3-none-any.whl", hash = "sha256:4a420a6cb6ee9d0c8da457c8a8573f709c2ff2e1a7542e2d38807ebbfe8ebd1d", size = 67368, upload-time = "2018-11-03T12:32:06.177Z" }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "sgmllib3k" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } + +[[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" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/35/fc009118a13187dd9731657c60138e5a7c2dea88681a7f04dc406af5da7d/slack_sdk-3.41.0.tar.gz", hash = "sha256:eb61eb12a65bebeca9cb5d36b3f799e836ed2be21b456d15df2627cfe34076ca", size = 250568, upload-time = "2026-03-12T16:10:11.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/df/2e4be347ff98281b505cc0ccf141408cdd25eb5ca9f3830deb361b2472d3/slack_sdk-3.41.0-py2.py3-none-any.whl", hash = "sha256:bb18dcdfff1413ec448e759cf807ec3324090993d8ab9111c74081623b692a89", size = 313885, upload-time = "2026-03-12T16:10:09.811Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "stix2" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "requests" }, + { name = "simplejson" }, + { name = "stix2-patterns" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/c8/103631824008f5a5259ff35f91442dccbb9ecad9a67f146b19d555679273/stix2-3.0.2.tar.gz", hash = "sha256:5bdaf3b7bd956a35b629c62b2c64fde8b2ce6f329b43ce09e12f672956507645", size = 141614, upload-time = "2026-02-12T08:44:50.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ae/d8d4c5bf65293543d0cbf2dd019b0169de3c7a37ae93ae4e2300d446805f/stix2-3.0.2-py2.py3-none-any.whl", hash = "sha256:f4814d29ebc332c92694fc8ddc96a4a5bfe2eac08494c0dec219719e7e654eb3", size = 161013, upload-time = "2026-02-12T08:44:48.771Z" }, +] + +[[package]] +name = "stix2-patterns" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/c1/adca6c1a5226cc3aa594d03b8562955563c6d7ed855aef071ad0e2feb2b8/stix2_patterns-2.1.2.tar.gz", hash = "sha256:b2059d36c1fd87740f3facc22a4147cde1e2b0acb8d5e4c08fbf04b1dc553185", size = 78811, upload-time = "2026-02-11T16:48:16.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/99/765db17c197ae1a971daae21a7080c83124c9a8ee2fc0f378145e2201b60/stix2_patterns-2.1.2-py2.py3-none-any.whl", hash = "sha256:e164e162936303c2e141760130e54f0d247d6272ebf4e1153b8e99e8a42c82ca", size = 81822, upload-time = "2026-02-11T16:48:15.172Z" }, +] + +[[package]] +name = "stripe" +version = "15.1.0b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/96/5771e2b88001dc05b22ef383f9bb7785992cb352b7869fd39063d5d1e12f/stripe-15.1.0b1.tar.gz", hash = "sha256:e9e369befa9188f2aa6c27ef996f963a0b036c15355a52219c6993b3f11b24a3", size = 1871054, upload-time = "2026-03-26T02:22:19.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/82/258877d8add2f56fdc4f9ea1790964d4a58490a5147108cc302e4d76f9d6/stripe-15.1.0b1-py3-none-any.whl", hash = "sha256:a84b12862274cc0f0ff22eae7869c4ae47b342410774fb0068a3d7afda572bfd", size = 2718400, upload-time = "2026-03-26T02:22:17.764Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "ua-parser" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser-builtins" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/0e/ed98be735bc89d5040e0c60f5620d0b8c04e9e7da99ed1459e8050e90a77/ua_parser-1.0.1.tar.gz", hash = "sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d", size = 728106, upload-time = "2025-02-01T14:13:32.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl", hash = "sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea", size = 31410, upload-time = "2025-02-01T14:13:28.458Z" }, +] + +[[package]] +name = "ua-parser-builtins" +version = "202603" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/6f/73a4d37deefb159556d39d654b5bad67b6874d1ad0b20b96fb5a04de3949/ua_parser_builtins-202603-py3-none-any.whl", hash = "sha256:67478397a68fac1a98fd0a31c416ea7c65a719141fc151d0211316f2cd337cc9", size = 89573, upload-time = "2026-03-01T20:50:02.491Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "user-agents" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/e1/63c5bfb485a945010c8cbc7a52f85573561737648d36b30394248730a7bc/user-agents-2.2.0.tar.gz", hash = "sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26", size = 9525, upload-time = "2020-08-23T06:01:56.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/1c/20bb3d7b2bad56d881e3704131ddedbb16eb787101306887dff349064662/user_agents-2.2.0-py3-none-any.whl", hash = "sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7", size = 9614, upload-time = "2020-08-23T06:01:54.047Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, +] From fdd8957e7521f353bdceb0f39f4c1851f0deede9 Mon Sep 17 00:00:00 2001 From: R1sh0bh-1 Date: Fri, 27 Mar 2026 19:25:15 +0530 Subject: [PATCH 096/109] fix: remove dead code and public sensor exposure from feeds table. Closes #1128 (#1133) * fix: remove dead code and public sensor exposure from feeds table * test: remove obsolete sensor tests and fix Register timeout --- .../src/components/feeds/tableColumns.jsx | 17 ----------- .../tests/components/auth/Register.test.jsx | 8 ++++++ .../components/feeds/TableColumns.test.jsx | 28 ------------------- 3 files changed, 8 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/feeds/tableColumns.jsx b/frontend/src/components/feeds/tableColumns.jsx index 7ad29d23..a7de0078 100644 --- a/frontend/src/components/feeds/tableColumns.jsx +++ b/frontend/src/components/feeds/tableColumns.jsx @@ -119,23 +119,6 @@ const feedsTableColumns = [
ASN: {asn ?? "-"}
Reputation: {ip_reputation || "-"}
Country: {attacker_country || "-"}
-
-
Sensors
- {row.original.sensors?.length > 0 ? ( - row.original.sensors.map((sensor, idx) => ( -
- {sensor.address} - {sensor.label && ( - - {" "} - ({sensor.label}) - - )} -
- )) - ) : ( -
-
- )}
diff --git a/frontend/tests/components/auth/Register.test.jsx b/frontend/tests/components/auth/Register.test.jsx index 2b68df4b..56ce4888 100644 --- a/frontend/tests/components/auth/Register.test.jsx +++ b/frontend/tests/components/auth/Register.test.jsx @@ -54,6 +54,10 @@ describe("Registration component", () => { await user.type(confirmPasswordInputElement, "GreedyBearPassword"); await user.type(companyNameInputElement, "companyname"); await user.type(companyRoleInputElement, "companyrole"); + + await waitFor(() => { + expect(submitButtonElement).not.toBeDisabled(); + }); await user.click(submitButtonElement); await waitFor(() => { @@ -136,6 +140,10 @@ describe("Registration component", () => { await user.type(companyNameInputElement, "companyname"); await user.type(companyRoleInputElement, "companyrole"); + await waitFor(() => { + expect(submitButtonElement).not.toBeDisabled(); + }); + // setting up the mock and clearing previous calls axios.post.mockClear(); let resolvePost; diff --git a/frontend/tests/components/feeds/TableColumns.test.jsx b/frontend/tests/components/feeds/TableColumns.test.jsx index 81aa0fa8..d5032fae 100644 --- a/frontend/tests/components/feeds/TableColumns.test.jsx +++ b/frontend/tests/components/feeds/TableColumns.test.jsx @@ -155,32 +155,4 @@ describe("Feeds table details popover", () => { expect(await screen.findByText(/Country:\s*-/i)).toBeInTheDocument(); }); - - test("shows sensors in popover details when sensors are provided", async () => { - const user = userEvent.setup(); - const detailsColumn = feedsTableColumns.find( - (column) => column.accessor === "details", - ); - - const row = { - id: "3", - original: { - sensors: [ - { address: "10.0.0.1", label: "AWS-West" }, - { address: "10.0.0.2", label: "" }, - ], - }, - }; - - const DetailsCell = detailsColumn.Cell; - render(); - - const detailsButton = screen.getByLabelText(/view details/i); - await user.click(detailsButton); - - expect(await screen.findByText(/Sensors/i)).toBeInTheDocument(); - expect(await screen.findByText(/10\.0\.0\.1/i)).toBeInTheDocument(); - expect(await screen.findByText(/\(AWS-West\)/i)).toBeInTheDocument(); - expect(await screen.findByText(/10\.0\.0\.2/i)).toBeInTheDocument(); - }); }); From 785f5764b245c92f9d04beb5d7a01b7b18e78f6d Mon Sep 17 00:00:00 2001 From: Ayush Agarwal <24bme108@nith.ac.in> Date: Fri, 27 Mar 2026 20:05:42 +0530 Subject: [PATCH 097/109] Fix/cowrie session str nonetype. Closes #1083 (#1141) * bug fixed * fix: correct indentation of CowrieSession.__str__ * fixed indentation * got confused --- greedybear/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/greedybear/models.py b/greedybear/models.py index 63bc3fff..685e4529 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -171,6 +171,8 @@ class Meta: ] def __str__(self): + if self.session_id is None: + return "New Session (unsaved)" return f"Session {hex(self.session_id)[2:]} from {self.source.name}" From 00722b5654fb54291c15138329c3e404b59aeb77 Mon Sep 17 00:00:00 2001 From: Sahitya Aryan <115429795+Sahityaaryan@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:07:10 +0530 Subject: [PATCH 098/109] refactor: move reputation update loggin into IocRepository. Closes #1107 (#1142) --- greedybear/cronjobs/mass_scanners.py | 13 +------------ greedybear/cronjobs/repositories/ioc.py | 1 + greedybear/cronjobs/tor_exit_nodes.py | 8 +------- tests/test_tor.py | 12 ------------ 4 files changed, 3 insertions(+), 31 deletions(-) diff --git a/greedybear/cronjobs/mass_scanners.py b/greedybear/cronjobs/mass_scanners.py index 7ab1fc94..1f01aeba 100644 --- a/greedybear/cronjobs/mass_scanners.py +++ b/greedybear/cronjobs/mass_scanners.py @@ -82,15 +82,4 @@ def run(self) -> None: scanner, created = self.mass_scanner_repo.get_or_create(ip_address, reason) if created: self.log.info(f"added new mass scanner {ip_address}") - self._update_old_ioc(ip_address) - - def _update_old_ioc(self, ip_address: str): - """ - Update the IP reputation of an existing IOC to mark it as a mass scanner. - - Args: - ip_address: IP address to update. - """ - updated = self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.MASS_SCANNER) - if updated: - self.log.debug(f"Updated IOC {ip_address} reputation to '{IpReputation.MASS_SCANNER}'") + self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.MASS_SCANNER) diff --git a/greedybear/cronjobs/repositories/ioc.py b/greedybear/cronjobs/repositories/ioc.py index e0f14b86..4cc04701 100644 --- a/greedybear/cronjobs/repositories/ioc.py +++ b/greedybear/cronjobs/repositories/ioc.py @@ -278,6 +278,7 @@ def update_ioc_reputation(self, ip_address: str, reputation: str) -> bool: ioc = IOC.objects.get(name=ip_address) ioc.ip_reputation = reputation ioc.save() + self.log.info(f"Updated IOC {ip_address} reputation to '{reputation}'") return True except IOC.DoesNotExist: return False diff --git a/greedybear/cronjobs/tor_exit_nodes.py b/greedybear/cronjobs/tor_exit_nodes.py index 8036fb38..8c62b125 100644 --- a/greedybear/cronjobs/tor_exit_nodes.py +++ b/greedybear/cronjobs/tor_exit_nodes.py @@ -41,16 +41,10 @@ def run(self) -> None: tor_node, created = self.tor_repo.get_or_create(ip_address) if created: self.log.info(f"Added new Tor exit node {ip_address}") - self._update_old_ioc(ip_address) + self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.TOR_EXIT_NODE) self.log.info("Completed download of Tor exit node list") except requests.RequestException as e: self.log.error(f"Failed to fetch Tor exit nodes: {e}") raise - - def _update_old_ioc(self, ip_address: str): - """Update the IP reputation of an existing IOC to mark it as a Tor exit node.""" - updated = self.ioc_repo.update_ioc_reputation(ip_address, IpReputation.TOR_EXIT_NODE) - if updated: - self.log.debug(f"Updated IOC {ip_address} reputation to '{IpReputation.TOR_EXIT_NODE}'") diff --git a/tests/test_tor.py b/tests/test_tor.py index 3e3d508b..b880fd20 100644 --- a/tests/test_tor.py +++ b/tests/test_tor.py @@ -84,15 +84,3 @@ def test_run_request_failure(self, mock_requests_get): # Act & Assert with self.assertRaises(requests.RequestException): self.cron.run() - - @patch("greedybear.cronjobs.tor_exit_nodes.is_valid_ipv4") - def test_update_old_ioc(self, mock_is_valid): - """Test updating existing IOCs.""" - # Arrange - self.mock_ioc_repo.update_ioc_reputation.return_value = True - - # Act - self.cron._update_old_ioc("1.2.3.4") - - # Assert - self.mock_ioc_repo.update_ioc_reputation.assert_called_once_with("1.2.3.4", IpReputation.TOR_EXIT_NODE) From 514be3c1a48666e9aea7c02eeddf078f5106b35b Mon Sep 17 00:00:00 2001 From: Varun chauhan <115783538+chauhan-varun@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:50:50 +0530 Subject: [PATCH 099/109] Redundant API fetching fix. Closes #1135 (#1144) * refactor: centralize attacker country data fetching and state management into a new Zustand store * created and verified a comprehensive unit test suite for the new store. * fix(frontend): reset shared store state in dashboard tests and restore empty-map behavior * refactor: simplify dashboard error rendering * fixed the error rendering --- .../components/dashboard/AttackOriginMap.jsx | 65 +----- .../src/components/dashboard/utils/charts.jsx | 25 +-- .../src/stores/useAttackerCountriesStore.jsx | 102 ++++++++++ .../tests/components/auth/Register.test.jsx | 4 +- .../AttackOriginCountriesChart.test.jsx | 21 +- .../dashboard/AttackOriginMap.test.jsx | 23 ++- .../stores/useAttackerCountriesStore.test.jsx | 190 ++++++++++++++++++ 7 files changed, 350 insertions(+), 80 deletions(-) create mode 100644 frontend/src/stores/useAttackerCountriesStore.jsx create mode 100644 frontend/tests/stores/useAttackerCountriesStore.test.jsx diff --git a/frontend/src/components/dashboard/AttackOriginMap.jsx b/frontend/src/components/dashboard/AttackOriginMap.jsx index 574f7394..a418c9eb 100644 --- a/frontend/src/components/dashboard/AttackOriginMap.jsx +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -5,37 +5,10 @@ import { Geography, ZoomableGroup, } from "react-simple-maps"; -import axios from "axios"; import { useTimePickerStore } from "@certego/certego-ui"; -import { IOC_ATTACKER_COUNTRIES_URI } from "../../constants/api"; +import useAttackerCountriesStore from "../../stores/useAttackerCountriesStore"; const WORLD_ATLAS_GEO_URL = `${import.meta.env.BASE_URL}countries-110m.json`; -// Normalise country names from T-Pot geoip to match Natural Earth names used by world-atlas@2. (https://github.com/topojson/world-atlas) -const 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", -}; - -function normalise(name) { - return NAME_FIXES[name] ?? name; -} - -// Interpolate between two hex colours by t ∈ [0, 1] function lerpColor(a, b, t) { const ah = parseInt(a.replace("#", ""), 16); const bh = parseInt(b.replace("#", ""), 16); @@ -58,10 +31,13 @@ const COLOR_HIGH = "#bd0026"; export default function AttackOriginMap() { const { range } = useTimePickerStore(); - const [countryData, setCountryData] = React.useState({}); - const [maxCount, setMaxCount] = React.useState(1); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); + const { + countryDataMap: countryData, + maxCount, + loading, + error, + fetchData, + } = useAttackerCountriesStore(); // tooltip state const [tooltip, setTooltip] = React.useState({ @@ -73,32 +49,13 @@ export default function AttackOriginMap() { }); React.useEffect(() => { - setLoading(true); - setError(null); - axios - .get(IOC_ATTACKER_COUNTRIES_URI, { params: { range } }) - .then((resp) => { - const map = {}; - let max = 0; - resp.data.forEach(({ country, count }) => { - const key = normalise(country); - map[key] = count; - if (count > max) max = count; - }); - setCountryData(map); - setMaxCount(max); - }) - .catch((err) => { - console.error("AttackOriginMap error:", err); - setError("Failed to load map data."); - }) - .finally(() => setLoading(false)); - }, [range]); + fetchData(range); + }, [range, fetchData]); const getColor = React.useCallback( (geoName) => { const count = countryData[geoName]; - if (!count) return COLOR_EMPTY; + 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) if (t < 0.5) return lerpColor(COLOR_LOW, COLOR_MID, t * 2); diff --git a/frontend/src/components/dashboard/utils/charts.jsx b/frontend/src/components/dashboard/utils/charts.jsx index bda08cc0..6091cddd 100644 --- a/frontend/src/components/dashboard/utils/charts.jsx +++ b/frontend/src/components/dashboard/utils/charts.jsx @@ -9,7 +9,6 @@ import { ResponsiveContainer, Cell, } from "recharts"; -import axios from "axios"; import { AnyChartWidget, @@ -22,8 +21,8 @@ import { FEEDS_STATISTICS_TYPES_URI, ENRICHMENT_STATISTICS_SOURCES_URI, ENRICHMENT_STATISTICS_REQUESTS_URI, - IOC_ATTACKER_COUNTRIES_URI, } from "../../../constants/api"; +import useAttackerCountriesStore from "../../../stores/useAttackerCountriesStore"; import { FEED_COLOR_MAP, ENRICHMENT_COLOR_MAP } from "../../../constants"; @@ -139,22 +138,16 @@ export const AttackOriginCountriesChart = React.memo(() => { console.debug("AttackOriginCountriesChart rendered!"); const { range } = useTimePickerStore(); - const [data, setData] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); + const { + rawData: data, + loading, + error, + fetchData, + } = useAttackerCountriesStore(); React.useEffect(() => { - setLoading(true); - setError(null); - axios - .get(IOC_ATTACKER_COUNTRIES_URI, { params: { range } }) - .then((resp) => setData(resp.data)) - .catch((err) => { - console.error("AttackOriginCountriesChart error:", err); - setError("Failed to load country data."); - }) - .finally(() => setLoading(false)); - }, [range]); + fetchData(range); + }, [range, fetchData]); if (loading) { return ( diff --git a/frontend/src/stores/useAttackerCountriesStore.jsx b/frontend/src/stores/useAttackerCountriesStore.jsx new file mode 100644 index 00000000..bf128847 --- /dev/null +++ b/frontend/src/stores/useAttackerCountriesStore.jsx @@ -0,0 +1,102 @@ +import axios from "axios"; +import { create } from "zustand"; +import { IOC_ATTACKER_COUNTRIES_URI } from "../constants/api"; + +// Normalise country names from T-Pot geoip to match Natural Earth names used by world-atlas@2. (https://github.com/topojson/world-atlas) +const 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", +}; + +function normalise(name) { + return NAME_FIXES[name] ?? name; +} + +const useAttackerCountriesStore = create((set, get) => ({ + rawData: [], + countryDataMap: {}, + maxCount: 0, + loading: false, + error: null, + lastRange: null, + currentController: null, + + fetchData: async (range) => { + const rangeStr = JSON.stringify(range); + if (get().lastRange === rangeStr && !get().error) return; + + if (get().currentController) { + get().currentController.abort(); + } + + const controller = new AbortController(); + set({ + loading: true, + error: null, + lastRange: rangeStr, + currentController: controller, + }); + + try { + const resp = await axios.get(IOC_ATTACKER_COUNTRIES_URI, { + params: { range }, + signal: controller.signal, + }); + + const rawData = Array.isArray(resp?.data) ? resp.data : []; + const countryDataMap = {}; + let maxCount = 0; + + rawData.forEach((item) => { + if (item && typeof item === "object") { + const { country, count } = item; + if (typeof country === "string") { + const key = normalise(country); + const countNum = Number(count) || 0; + countryDataMap[key] = countNum; + if (countNum > maxCount) maxCount = countNum; + } + } + }); + + if (get().currentController === controller) { + set({ + rawData, + countryDataMap, + maxCount, + loading: false, + currentController: null, + }); + } + } catch (err) { + if (axios.isCancel(err)) { + return; + } + console.error("useAttackerCountriesStore error:", err); + if (get().currentController === controller) { + set({ + error: "Failed to load attacker countries data.", + loading: false, + currentController: null, + }); + } + } + }, +})); + +export default useAttackerCountriesStore; diff --git a/frontend/tests/components/auth/Register.test.jsx b/frontend/tests/components/auth/Register.test.jsx index 56ce4888..8de88ae5 100644 --- a/frontend/tests/components/auth/Register.test.jsx +++ b/frontend/tests/components/auth/Register.test.jsx @@ -76,7 +76,7 @@ describe("Registration component", () => { }, }); }); - }); + }, 15000); test("Show password checkbox", async () => { const user = userEvent.setup(); @@ -174,5 +174,5 @@ describe("Registration component", () => { await waitFor(() => { expect(submitButtonElement).not.toBeDisabled(); }); - }); + }, 15000); }); diff --git a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx index 421cdb56..03320e0d 100644 --- a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx @@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import axios from "axios"; import { AttackOriginCountriesChart } from "../../../src/components/dashboard/utils/charts"; import { IOC_ATTACKER_COUNTRIES_URI } from "../../../src/constants/api"; +import useAttackerCountriesStore from "../../../src/stores/useAttackerCountriesStore"; vi.mock("axios"); @@ -43,6 +44,15 @@ const SIXTEEN_COUNTRIES = Array.from({ length: 16 }, (_, i) => ({ describe("AttackOriginCountriesChart", () => { beforeEach(() => { + useAttackerCountriesStore.setState({ + rawData: [], + countryDataMap: {}, + maxCount: 0, + loading: false, + error: null, + lastRange: null, + currentController: null, + }); vi.clearAllMocks(); }); @@ -57,7 +67,7 @@ describe("AttackOriginCountriesChart", () => { render(); await waitFor(() => expect( - screen.getByText("Failed to load country data."), + screen.getByText("Failed to load attacker countries data."), ).toBeInTheDocument(), ); }); @@ -78,9 +88,12 @@ describe("AttackOriginCountriesChart", () => { axios.get.mockResolvedValue({ data: [] }); render(); await waitFor(() => - expect(axios.get).toHaveBeenCalledWith(IOC_ATTACKER_COUNTRIES_URI, { - params: { range: "7d" }, - }), + expect(axios.get).toHaveBeenCalledWith( + IOC_ATTACKER_COUNTRIES_URI, + expect.objectContaining({ + params: { range: "7d" }, + }), + ), ); }); diff --git a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx index 0f3d553e..493d90b2 100644 --- a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx @@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import axios from "axios"; import AttackOriginMap from "../../../src/components/dashboard/AttackOriginMap"; import { IOC_ATTACKER_COUNTRIES_URI } from "../../../src/constants/api"; +import useAttackerCountriesStore from "../../../src/stores/useAttackerCountriesStore"; vi.mock("axios"); @@ -49,6 +50,15 @@ const COUNTRIES_DATA = [ describe("AttackOriginMap", () => { beforeEach(() => { + useAttackerCountriesStore.setState({ + rawData: [], + countryDataMap: {}, + maxCount: 0, + loading: false, + error: null, + lastRange: null, + currentController: null, + }); vi.clearAllMocks(); }); @@ -62,7 +72,9 @@ describe("AttackOriginMap", () => { axios.get.mockRejectedValue(new Error("Network error")); render(); await waitFor(() => - expect(screen.getByText("Failed to load map data.")).toBeInTheDocument(), + expect( + screen.getByText("Failed to load attacker countries data."), + ).toBeInTheDocument(), ); }); @@ -70,9 +82,12 @@ describe("AttackOriginMap", () => { axios.get.mockResolvedValue({ data: [] }); render(); await waitFor(() => - expect(axios.get).toHaveBeenCalledWith(IOC_ATTACKER_COUNTRIES_URI, { - params: { range: "7d" }, - }), + expect(axios.get).toHaveBeenCalledWith( + IOC_ATTACKER_COUNTRIES_URI, + expect.objectContaining({ + params: { range: "7d" }, + }), + ), ); }); diff --git a/frontend/tests/stores/useAttackerCountriesStore.test.jsx b/frontend/tests/stores/useAttackerCountriesStore.test.jsx new file mode 100644 index 00000000..37da99bd --- /dev/null +++ b/frontend/tests/stores/useAttackerCountriesStore.test.jsx @@ -0,0 +1,190 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import axios from "axios"; +import useAttackerCountriesStore from "../../src/stores/useAttackerCountriesStore"; +import { IOC_ATTACKER_COUNTRIES_URI } from "../../src/constants/api"; + +vi.mock("axios"); + +const createDeferred = () => { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}; + +describe("useAttackerCountriesStore", () => { + beforeEach(() => { + useAttackerCountriesStore.setState({ + rawData: [], + countryDataMap: {}, + maxCount: 0, + loading: false, + error: null, + lastRange: null, + currentController: null, + }); + vi.clearAllMocks(); + }); + + describe("Initial State", () => { + test("initial state is correct", () => { + const state = useAttackerCountriesStore.getState(); + expect(state.rawData).toEqual([]); + expect(state.countryDataMap).toEqual({}); + expect(state.maxCount).toBe(0); + expect(state.loading).toBe(false); + expect(state.error).toBe(null); + }); + }); + + describe("fetchData", () => { + const mockRange = "24h"; + const rangeStr = JSON.stringify(mockRange); + const mockData = [ + { country: "United States", count: 100 }, + { country: "Italy", count: 50 }, + ]; + + test("successfully fetches and normalizes data", async () => { + axios.get.mockResolvedValue({ data: mockData }); + + await useAttackerCountriesStore.getState().fetchData(mockRange); + + const state = useAttackerCountriesStore.getState(); + expect(state.rawData).toEqual(mockData); + expect(state.countryDataMap).toEqual({ + "United States of America": 100, + Italy: 50, + }); + expect(state.maxCount).toBe(100); + expect(state.loading).toBe(false); + expect(state.lastRange).toBe(rangeStr); + expect(axios.get).toHaveBeenCalledWith( + IOC_ATTACKER_COUNTRIES_URI, + expect.any(Object), + ); + }); + + test("prevents redundant fetches for the same range (caching)", async () => { + axios.get.mockResolvedValue({ data: mockData }); + + // First call + await useAttackerCountriesStore.getState().fetchData(mockRange); + expect(axios.get).toHaveBeenCalledTimes(1); + + // Second call for same range + await useAttackerCountriesStore.getState().fetchData(mockRange); + expect(axios.get).toHaveBeenCalledTimes(1); // Still 1 + }); + + test("handles empty results correctly and caches them", async () => { + axios.get.mockResolvedValue({ data: [] }); + + await useAttackerCountriesStore.getState().fetchData(mockRange); + expect(axios.get).toHaveBeenCalledTimes(1); + + // Second call for same range (empty) + await useAttackerCountriesStore.getState().fetchData(mockRange); + expect(axios.get).toHaveBeenCalledTimes(1); // Caching still works for empty data + }); + + test("prevents simultaneous calls for the same range", async () => { + const deferred = createDeferred(); + axios.get.mockReturnValue(deferred.promise); + + const fetchPromise1 = useAttackerCountriesStore + .getState() + .fetchData(mockRange); + const fetchPromise2 = useAttackerCountriesStore + .getState() + .fetchData(mockRange); + + expect(useAttackerCountriesStore.getState().loading).toBe(true); + + deferred.resolve({ data: mockData }); + await Promise.all([fetchPromise1, fetchPromise2]); + + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + test("cancels in-flight requests when a new range is selected (race condition)", async () => { + const deferred1 = createDeferred(); + const deferred2 = createDeferred(); + + // First mock returns pending promise + axios.get.mockReturnValueOnce(deferred1.promise); + // Second mock returns another pending promise + axios.get.mockReturnValueOnce(deferred2.promise); + + const fetchData = useAttackerCountriesStore.getState().fetchData; + + // Start first fetch + const fetch1 = fetchData("24h"); + const controller1 = + useAttackerCountriesStore.getState().currentController; + const abortSpy1 = vi.spyOn(controller1, "abort"); + + // Start second fetch for a different range + const fetch2 = fetchData("7d"); + + // Verify first controller was aborted + expect(abortSpy1).toHaveBeenCalled(); + + deferred1.resolve({ data: [] }); + deferred2.resolve({ data: mockData }); + + await Promise.all([fetch1, fetch2]); + + expect(axios.get).toHaveBeenCalledTimes(2); + expect(useAttackerCountriesStore.getState().rawData).toEqual(mockData); + }); + + test("sets error state on failure", async () => { + axios.get.mockRejectedValue(new Error("Network Error")); + + await useAttackerCountriesStore.getState().fetchData(mockRange); + + const state = useAttackerCountriesStore.getState(); + expect(state.error).toBe("Failed to load attacker countries data."); + expect(state.loading).toBe(false); + }); + + test("maintains loading state when a request is cancelled by a newer one", async () => { + const deferred1 = createDeferred(); + const deferred2 = createDeferred(); + + axios.get.mockReturnValueOnce(deferred1.promise); + axios.get.mockReturnValueOnce(deferred2.promise); + + const fetchData = useAttackerCountriesStore.getState().fetchData; + + // Start first fetch + const fetch1 = fetchData("24h"); + expect(useAttackerCountriesStore.getState().loading).toBe(true); + + // Start second fetch (cancels first) + const fetch2 = fetchData("7d"); + + // Simulate first request's rejection due to cancellation + axios.isCancel = vi.fn().mockReturnValue(true); + deferred1.reject(new Error("Canceled")); + await fetch1; + + // Verify loading is STILL true because the second request is still in flight + expect(useAttackerCountriesStore.getState().loading).toBe(true); + expect(useAttackerCountriesStore.getState().error).toBe(null); + + // Finish second request + deferred2.resolve({ data: mockData }); + await fetch2; + + // Now loading should be false + expect(useAttackerCountriesStore.getState().loading).toBe(false); + expect(useAttackerCountriesStore.getState().rawData).toEqual(mockData); + }); + }); +}); From 602e786635e2026b55d5b8089154d484ccb8cf72 Mon Sep 17 00:00:00 2001 From: Manik Date: Mon, 30 Mar 2026 21:12:03 +0530 Subject: [PATCH 100/109] fix(cronjobs): update missing autonomous system names from enrichment data. Closes #1130 (#1132) --- .../cronjobs/repositories/autonomous_system.py | 9 +++++---- tests/test_as_repo.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/greedybear/cronjobs/repositories/autonomous_system.py b/greedybear/cronjobs/repositories/autonomous_system.py index e8c18e93..9311df09 100644 --- a/greedybear/cronjobs/repositories/autonomous_system.py +++ b/greedybear/cronjobs/repositories/autonomous_system.py @@ -25,9 +25,11 @@ def get_or_create(self, asn: int, name: str) -> AutonomousSystem: AutonomousSystem instance """ if asn in self._cache: - return self._cache[asn] - - as_obj, created = AutonomousSystem.objects.get_or_create(asn=asn, defaults={"name": name or ""}) + as_obj = self._cache[asn] + created = False + else: + as_obj, created = AutonomousSystem.objects.get_or_create(asn=asn, defaults={"name": name or ""}) + self._cache[asn] = as_obj if created: self.log.info(f"Created new AS {asn} with name '{name}'") @@ -36,5 +38,4 @@ def get_or_create(self, asn: int, name: str) -> AutonomousSystem: as_obj.save(update_fields=["name"]) self.log.info(f"Updated AS {asn} name to '{name}'") - self._cache[asn] = as_obj return as_obj diff --git a/tests/test_as_repo.py b/tests/test_as_repo.py index 932d9037..90f68819 100644 --- a/tests/test_as_repo.py +++ b/tests/test_as_repo.py @@ -45,6 +45,22 @@ def test_update_existing_asn_with_missing_name(self): as_obj = self.repo.get_or_create(asn_number, "intelowl/@GB") self.assertEqual(as_obj.name, "intelowl/@GB") + def test_preloaded_asn_updates_missing_name(self): + """An ASN preloaded into the cache with an empty name should still be updated.""" + asn_number = 64510 + AutonomousSystem.objects.create(asn=asn_number, name="") + + # Re-initialize the repo so the DB object is preloaded into the cache + self.repo = ASRepository() + self.assertIn(asn_number, self.repo._cache) + + as_obj = self.repo.get_or_create(asn_number, "CacheUpdateTest") + self.assertEqual(as_obj.name, "CacheUpdateTest") + + # Verify it was updated in the database + db_obj = AutonomousSystem.objects.get(asn=asn_number) + self.assertEqual(db_obj.name, "CacheUpdateTest") + def test_logging_on_create_and_update(self): """Ensure the logger logs info messages when creating or updating ASNs.""" asn_number = 64504 From c443b180b7eab667e7d6732a040a188d5405d90b Mon Sep 17 00:00:00 2001 From: Manik Date: Tue, 31 Mar 2026 11:53:41 +0530 Subject: [PATCH 101/109] fix(tests): stabilize flaky Register.test.jsx. Closes #1148 (#1151) --- .../tests/components/auth/Register.test.jsx | 80 +++++++++---------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/frontend/tests/components/auth/Register.test.jsx b/frontend/tests/components/auth/Register.test.jsx index 8de88ae5..5348a49f 100644 --- a/frontend/tests/components/auth/Register.test.jsx +++ b/frontend/tests/components/auth/Register.test.jsx @@ -1,6 +1,6 @@ import React from "react"; import "@testing-library/jest-dom"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import axios from "axios"; import userEvent from "@testing-library/user-event"; @@ -9,9 +9,40 @@ import { AUTH_BASE_URI } from "../../../src/constants/api"; vi.mock("axios"); +function fillRegistrationForm() { + fireEvent.change(screen.getByLabelText("First Name"), { + target: { value: "firstname" }, + }); + fireEvent.change(screen.getByLabelText("Last Name"), { + target: { value: "lastname" }, + }); + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "test@test.com" }, + }); + fireEvent.change(screen.getByLabelText("Username"), { + target: { value: "test_user" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "GreedyBearPassword" }, + }); + fireEvent.change(screen.getByLabelText("Confirm Password"), { + target: { value: "GreedyBearPassword" }, + }); + fireEvent.change(screen.getByLabelText("Company/ Organization"), { + target: { value: "companyname" }, + }); + fireEvent.change(screen.getByLabelText("Role"), { + target: { value: "companyrole" }, + }); +} + describe("Registration component", () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + test("User registration", async () => { - // mock user interaction: reccomanded to put this at the start of the test const user = userEvent.setup(); render( @@ -46,14 +77,7 @@ describe("Registration component", () => { expect(submitButtonElement).toBeInTheDocument(); // user populates the registration form and submit - await user.type(firstNameInputElement, "firstname"); - await user.type(lastNameInputElement, "lastname"); - await user.type(emailInputElement, "test@test.com"); - await user.type(usernameInputElement, "test_user"); - await user.type(passwordInputElement, "GreedyBearPassword"); - await user.type(confirmPasswordInputElement, "GreedyBearPassword"); - await user.type(companyNameInputElement, "companyname"); - await user.type(companyRoleInputElement, "companyrole"); + fillRegistrationForm(); await waitFor(() => { expect(submitButtonElement).not.toBeDisabled(); @@ -76,7 +100,7 @@ describe("Registration component", () => { }, }); }); - }, 15000); + }); test("Show password checkbox", async () => { const user = userEvent.setup(); @@ -101,45 +125,19 @@ describe("Registration component", () => { test("Double-clicking Register while submitting does not trigger duplicate requests", async () => { const user = userEvent.setup(); - // Clear the storage to prevent state pollution from previous calls - localStorage.clear(); - vi.resetModules(); - - // Then reimporting to get a fresh state - const { default: Register } = - await import("../../../src/components/auth/Register"); - render( , ); - const firstNameInputElement = screen.getByLabelText("First Name"); - const lastNameInputElement = screen.getByLabelText("Last Name"); - const emailInputElement = screen.getByLabelText("Email"); - const usernameInputElement = screen.getByLabelText("Username"); - const passwordInputElement = screen.getByLabelText("Password"); - const confirmPasswordInputElement = - screen.getByLabelText("Confirm Password"); - const companyNameInputElement = screen.getByLabelText( - "Company/ Organization", - ); - const companyRoleInputElement = screen.getByLabelText("Role"); + // Populating the form + fillRegistrationForm(); + const submitButtonElement = screen.getByRole("button", { name: /Register/i, }); - // Populating the form - await user.type(firstNameInputElement, "firstname"); - await user.type(lastNameInputElement, "lastname"); - await user.type(emailInputElement, "test@test.com"); - await user.type(usernameInputElement, "test_user"); - await user.type(passwordInputElement, "GreedyBearPassword"); - await user.type(confirmPasswordInputElement, "GreedyBearPassword"); - await user.type(companyNameInputElement, "companyname"); - await user.type(companyRoleInputElement, "companyrole"); - await waitFor(() => { expect(submitButtonElement).not.toBeDisabled(); }); @@ -174,5 +172,5 @@ describe("Registration component", () => { await waitFor(() => { expect(submitButtonElement).not.toBeDisabled(); }); - }, 15000); + }); }); From 792f6a3f6f3f70e2eb2eaf21e71499b0725fece3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:03:52 +0200 Subject: [PATCH 102/109] build(deps): bump library/nginx in /docker (#1153) Bumps library/nginx from 1.29.6-alpine to 1.29.7-alpine. --- updated-dependencies: - dependency-name: library/nginx dependency-version: 1.29.7-alpine 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> --- docker/Dockerfile_nginx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile_nginx b/docker/Dockerfile_nginx index 957be4bb..9b8e1349 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -1,4 +1,4 @@ -FROM library/nginx:1.29.6-alpine +FROM library/nginx:1.29.7-alpine ENV NGINX_LOG_DIR=/var/log/nginx From 8f33cbbca969810a6284d3a7cb3c103ff4f2f2ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:04:18 +0200 Subject: [PATCH 103/109] build(deps-dev): bump @vitest/coverage-v8 in /frontend (#1154) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.1 to 4.1.2. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.2 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> --- frontend/package-lock.json | 215 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 108 insertions(+), 109 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d02d472e..f83547ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,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.1", + "@vitest/coverage-v8": "^4.1.2", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -1957,13 +1957,13 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", - "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", "dev": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.1", + "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1971,14 +1971,14 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.1", - "vitest": "4.1.1" + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1987,29 +1987,29 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", - "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", - "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "dependencies": { - "@vitest/spy": "4.1.1", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2030,24 +2030,24 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", - "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", - "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "dependencies": { - "@vitest/utils": "4.1.1", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -2055,13 +2055,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", - "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "dependencies": { - "@vitest/pretty-format": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2070,23 +2070,23 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", - "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", - "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "4.1.1", + "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -8361,11 +8361,10 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, - "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -8839,18 +8838,18 @@ } }, "node_modules/vitest": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", - "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "dependencies": { - "@vitest/expect": "4.1.1", - "@vitest/mocker": "4.1.1", - "@vitest/pretty-format": "4.1.1", - "@vitest/runner": "4.1.1", - "@vitest/snapshot": "4.1.1", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8861,7 +8860,7 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", + "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, @@ -8878,10 +8877,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.1", - "@vitest/browser-preview": "4.1.1", - "@vitest/browser-webdriverio": "4.1.1", - "@vitest/ui": "4.1.1", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -10389,13 +10388,13 @@ } }, "@vitest/coverage-v8": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", - "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", "dev": true, "requires": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.1", + "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -10403,80 +10402,80 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" } }, "@vitest/expect": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", - "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "requires": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" } }, "@vitest/mocker": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", - "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "requires": { - "@vitest/spy": "4.1.1", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" } }, "@vitest/pretty-format": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", - "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "requires": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" } }, "@vitest/runner": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", - "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "requires": { - "@vitest/utils": "4.1.1", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "@vitest/snapshot": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", - "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "requires": { - "@vitest/pretty-format": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "@vitest/spy": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", - "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true }, "@vitest/utils": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", - "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "requires": { - "@vitest/pretty-format": "4.1.1", + "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "dependencies": { "convert-source-map": { @@ -14849,9 +14848,9 @@ } }, "tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true }, "tldts": { @@ -15143,18 +15142,18 @@ } }, "vitest": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", - "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "requires": { - "@vitest/expect": "4.1.1", - "@vitest/mocker": "4.1.1", - "@vitest/pretty-format": "4.1.1", - "@vitest/runner": "4.1.1", - "@vitest/snapshot": "4.1.1", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -15165,7 +15164,7 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", + "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, diff --git a/frontend/package.json b/frontend/package.json index 7c67cf62..1922918a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,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.1", + "@vitest/coverage-v8": "^4.1.2", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", From 0d877869d8b3c7e6ee04586544c1a8f21d7191a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:04:31 +0200 Subject: [PATCH 104/109] build(deps-dev): bump vite from 8.0.2 to 8.0.3 in /frontend (#1155) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.2 to 8.0.3. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.3 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> --- frontend/package-lock.json | 328 +++++++++++++++++++------------------ frontend/package.json | 2 +- 2 files changed, 168 insertions(+), 162 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f83547ab..deab3b0f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,7 @@ "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.5.0", - "vite": "^8.0.2", + "vite": "^8.0.3", "vitest": "^4.0.18" } }, @@ -722,6 +722,7 @@ "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" @@ -733,6 +734,7 @@ "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -743,6 +745,7 @@ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1050,19 +1053,21 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "dev": true, "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -1425,9 +1430,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", "cpu": [ "arm64" ], @@ -1441,9 +1446,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", "cpu": [ "arm64" ], @@ -1457,9 +1462,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", "cpu": [ "x64" ], @@ -1473,9 +1478,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", "cpu": [ "x64" ], @@ -1489,9 +1494,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", "cpu": [ "arm" ], @@ -1505,9 +1510,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", "cpu": [ "arm64" ], @@ -1521,9 +1526,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", "cpu": [ "arm64" ], @@ -1537,9 +1542,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", "cpu": [ "ppc64" ], @@ -1553,9 +1558,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", "cpu": [ "s390x" ], @@ -1569,9 +1574,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", "cpu": [ "x64" ], @@ -1585,9 +1590,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", "cpu": [ "x64" ], @@ -1601,9 +1606,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", "cpu": [ "arm64" ], @@ -1617,9 +1622,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", "cpu": [ "wasm32" ], @@ -1633,9 +1638,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", "cpu": [ "arm64" ], @@ -1649,9 +1654,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", "cpu": [ "x64" ], @@ -7300,13 +7305,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", "dev": true, "dependencies": { "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.11" + "@rolldown/pluginutils": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7315,27 +7320,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", "dev": true }, "node_modules/rtl-css-js": { @@ -8749,15 +8754,15 @@ } }, "node_modules/vite": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", - "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", + "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.11", + "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "bin": { @@ -8826,9 +8831,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "engines": { "node": ">=12" @@ -9609,6 +9614,7 @@ "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "optional": true, + "peer": true, "requires": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" @@ -9620,6 +9626,7 @@ "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "optional": true, + "peer": true, "requires": { "tslib": "^2.4.0" } @@ -9630,6 +9637,7 @@ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "optional": true, + "peer": true, "requires": { "tslib": "^2.4.0" } @@ -9873,14 +9881,12 @@ "dev": true }, "@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "dev": true, "optional": true, "requires": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, @@ -10038,93 +10044,93 @@ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==" }, "@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", "dev": true, "optional": true }, "@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", "dev": true, "optional": true }, "@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", "dev": true, "optional": true }, "@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", "dev": true, "optional": true }, "@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", "dev": true, "optional": true }, "@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", "dev": true, "optional": true }, "@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", "dev": true, "optional": true }, "@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", "dev": true, "optional": true }, "@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", "dev": true, "optional": true }, "@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", "dev": true, "optional": true }, "@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", "dev": true, "optional": true }, "@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", "dev": true, "optional": true }, "@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", "dev": true, "optional": true, "requires": { @@ -10132,16 +10138,16 @@ } }, "@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", "dev": true, "optional": true }, "@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", "dev": true, "optional": true }, @@ -14109,34 +14115,34 @@ } }, "rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", "dev": true, "requires": { "@oxc-project/types": "=0.122.0", - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11", - "@rolldown/pluginutils": "1.0.0-rc.11" + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12", + "@rolldown/pluginutils": "1.0.0-rc.12" }, "dependencies": { "@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", "dev": true } } @@ -15120,23 +15126,23 @@ } }, "vite": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", - "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "requires": { "fsevents": "~2.3.3", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", + "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.11", + "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "dependencies": { "picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true } } diff --git a/frontend/package.json b/frontend/package.json index 1922918a..55359742 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,7 +60,7 @@ "jsdom": "^29.0.1", "prettier": "^3.8.1", "stylelint": "^17.5.0", - "vite": "^8.0.2", + "vite": "^8.0.3", "vitest": "^4.0.18" } } From ef538e1540ef1ed7b4745da343e6fc0492fc7ce5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:16:36 +0200 Subject: [PATCH 105/109] build(deps-dev): bump stylelint from 17.5.0 to 17.6.0 in /frontend (#1156) Bumps [stylelint](https://github.com/stylelint/stylelint) from 17.5.0 to 17.6.0. - [Release notes](https://github.com/stylelint/stylelint/releases) - [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md) - [Commits](https://github.com/stylelint/stylelint/compare/17.5.0...17.6.0) --- updated-dependencies: - dependency-name: stylelint dependency-version: 17.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 44 +++++++++++++++++--------------------- frontend/package.json | 2 +- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index deab3b0f..f76b5b69 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,7 +40,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^29.0.1", "prettier": "^3.8.1", - "stylelint": "^17.5.0", + "stylelint": "^17.6.0", "vite": "^8.0.3", "vitest": "^4.0.18" } @@ -4869,6 +4869,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "peer": true, "engines": { "node": ">=0.8.19" } @@ -7664,7 +7665,6 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "ISC", "engines": { "node": ">=14" }, @@ -7992,9 +7992,9 @@ } }, "node_modules/stylelint": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.5.0.tgz", - "integrity": "sha512-o/NS6zhsPZFmgUm5tXX4pVNg1XDOZSlucLdf2qow/lVn4JIyzZIQ5b3kad1ugqUj3GSIgr2u5lQw7X8rjqw33g==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.6.0.tgz", + "integrity": "sha512-tokrsMIVAR9vAQ/q3UVEr7S0dGXCi7zkCezPRnS2kqPUulvUh5Vgfwngrk4EoAoW7wnrThqTdnTFN5Ra7CaxIg==", "dev": true, "funding": [ { @@ -8009,7 +8009,7 @@ "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.0.29", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", @@ -8028,7 +8028,6 @@ "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", - "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", @@ -8043,7 +8042,7 @@ "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", - "write-file-atomic": "^7.0.0" + "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" @@ -9127,13 +9126,11 @@ "peer": true }, "node_modules/write-file-atomic": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", - "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", "dev": true, - "license": "ISC", "dependencies": { - "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" }, "engines": { @@ -12499,7 +12496,8 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true + "dev": true, + "peer": true }, "indent-string": { "version": "4.0.0", @@ -14594,14 +14592,14 @@ "peer": true }, "stylelint": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.5.0.tgz", - "integrity": "sha512-o/NS6zhsPZFmgUm5tXX4pVNg1XDOZSlucLdf2qow/lVn4JIyzZIQ5b3kad1ugqUj3GSIgr2u5lQw7X8rjqw33g==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.6.0.tgz", + "integrity": "sha512-tokrsMIVAR9vAQ/q3UVEr7S0dGXCi7zkCezPRnS2kqPUulvUh5Vgfwngrk4EoAoW7wnrThqTdnTFN5Ra7CaxIg==", "dev": true, "requires": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.0.29", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", @@ -14620,7 +14618,6 @@ "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", - "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", @@ -14635,7 +14632,7 @@ "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", - "write-file-atomic": "^7.0.0" + "write-file-atomic": "^7.0.1" }, "dependencies": { "@csstools/css-syntax-patches-for-csstree": { @@ -15324,12 +15321,11 @@ "peer": true }, "write-file-atomic": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", - "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", "dev": true, "requires": { - "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, diff --git a/frontend/package.json b/frontend/package.json index 55359742..0f26718e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,7 +59,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "jsdom": "^29.0.1", "prettier": "^3.8.1", - "stylelint": "^17.5.0", + "stylelint": "^17.6.0", "vite": "^8.0.3", "vitest": "^4.0.18" } From 94d7ec3b3e9c06b495d298101e1c011d8295ae3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:18:11 +0200 Subject: [PATCH 106/109] build(deps): bump axios from 1.13.6 to 1.14.0 in /frontend (#1157) Bumps [axios](https://github.com/axios/axios) from 1.13.6 to 1.14.0. - [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.13.6...v1.14.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.14.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> --- frontend/package-lock.json | 33 ++++++++++++++++++--------------- frontend/package.json | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f76b5b69..9bfe1abb 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.13.6", + "axios": "^1.14.0", "axios-hooks": "^3.0.4", "bootstrap": ">=5.3.8", "formik": "^2.4.9", @@ -2427,13 +2427,13 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axios-hooks": { @@ -6721,9 +6721,12 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -10725,13 +10728,13 @@ "dev": true }, "axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "requires": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "axios-hooks": { @@ -13708,9 +13711,9 @@ } }, "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" }, "punycode": { "version": "2.3.1", diff --git a/frontend/package.json b/frontend/package.json index 0f26718e..67a24135 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@certego/certego-ui": "0.1.14", - "axios": "^1.13.6", + "axios": "^1.14.0", "axios-hooks": "^3.0.4", "bootstrap": ">=5.3.8", "formik": "^2.4.9", From d6e671f6f9e1fd9056d58dcea07392312ac20f91 Mon Sep 17 00:00:00 2001 From: SHUBHAM CHAUHAN <119105844+Demiserular@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:59:58 +0530 Subject: [PATCH 107/109] test: add LSH clustering coverage for #975 (#1150) * test: add LSH clustering coverage for #975 * test: address PR feedback (use django TestCase, add edge cases) * tests: use SimpleTestCase and remove redundant lsh mock test --- tests/test_lsh.py | 92 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/test_lsh.py diff --git a/tests/test_lsh.py b/tests/test_lsh.py new file mode 100644 index 00000000..0b31b047 --- /dev/null +++ b/tests/test_lsh.py @@ -0,0 +1,92 @@ +from django.test import SimpleTestCase + +from greedybear.cronjobs.commands.lsh import LSHConnectedComponents, UnionFind + + +class UnionFindTestCase(SimpleTestCase): + def test_find_representative_applies_path_compression(self): + u = UnionFind(4) + u.parents = [0, 0, 1, 2] + + representative = u.find_representative(3) + + self.assertEqual(representative, 0) + self.assertEqual(u.parents[3], 0) + self.assertEqual(u.parents[2], 0) + + def test_union_merges_two_sets(self): + u = UnionFind(3) + + u.union(0, 1) + + self.assertEqual(u.find_representative(0), u.find_representative(1)) + self.assertNotEqual(u.find_representative(0), u.find_representative(2)) + + def test_union_same_element(self): + u = UnionFind(3) + + u.union(1, 1) + + self.assertEqual(u.find_representative(1), 1) + self.assertEqual(u.find_representative(0), 0) + self.assertEqual(u.find_representative(2), 2) + + +class LSHConnectedComponentsTestCase(SimpleTestCase): + def test_get_min_hashes_generates_matching_signatures(self): + lsh = LSHConnectedComponents(num_perm=64) + sequences = [ + ["ls", "-la", "/tmp"], + ["ls", "-la", "/tmp"], + ["python", "-m", "http.server"], + ] + + min_hashes = lsh._get_min_hashes(sequences) + + self.assertEqual(len(min_hashes), len(sequences)) + self.assertEqual(min_hashes[0].jaccard(min_hashes[1]), 1.0) + self.assertLess(min_hashes[0].jaccard(min_hashes[2]), 0.5) + + def test_get_labels_maps_components_to_compact_labels(self): + sequences = [["a"], ["b"], ["c"], ["d"], ["e"]] + u = UnionFind(len(sequences)) + u.union(0, 2) + u.union(2, 4) + u.union(1, 3) + + labels = LSHConnectedComponents()._get_labels(sequences, u) + + self.assertEqual(labels, [0, 1, 0, 1, 0]) + + def test_get_components_returns_empty_for_empty_input(self): + labels = LSHConnectedComponents().get_components([]) + self.assertEqual(labels, []) + + def test_get_components_single_element_input(self): + sequences = [["a", "b", "c"]] + labels = LSHConnectedComponents().get_components(sequences) + self.assertEqual(labels, [0]) + + def test_get_components_all_identical_sequences(self): + sequences = [ + ["same", "sequence"], + ["same", "sequence"], + ["same", "sequence"], + ] + labels = LSHConnectedComponents().get_components(sequences) + self.assertEqual(labels, [0, 0, 0]) + + def test_get_components_groups_similar_sequences(self): + sequences = [ + ["echo", "hello", "world"], + ["echo", "hello", "world"], + ["wget", "http://example.com"], + ["wget", "http://example.com"], + ] + + labels = LSHConnectedComponents(threshold=0.8, num_perm=256).get_components(sequences) + + self.assertEqual(len(labels), 4) + self.assertEqual(labels[0], labels[1]) + self.assertEqual(labels[2], labels[3]) + self.assertNotEqual(labels[0], labels[2]) From a21b32b24793a3622511439b5fad3a29e62edb32 Mon Sep 17 00:00:00 2001 From: armoredvortex <66690593+armoredvortex@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:33:44 +0530 Subject: [PATCH 108/109] Enhance session revoke UX. Closes #1125 (#1137) * add endpoint to revoke other user sessions * add modal for session revoke confirmation; revoke other sessions button * add test for session revoke endpoint * add frontend tests for session management * move revoke other sessions to TokenSessionsViewSet subclass * token revocation logic into a separate function * revoke session cancelled when confirmation rejected * add comment to clarify token revocation logic --- authentication/views.py | 28 ++- .../components/me/sessions/SessionList.jsx | 182 ++++++++++++------ frontend/src/components/me/sessions/api.js | 39 +++- .../me/sessions/SessionList.test.jsx | 164 ++++++++++++++++ .../tests/components/me/sessions/api.test.js | 77 ++++++++ tests/authentication/test_auth.py | 39 ++++ 6 files changed, 457 insertions(+), 72 deletions(-) create mode 100644 frontend/tests/components/me/sessions/SessionList.test.jsx create mode 100644 frontend/tests/components/me/sessions/api.test.js diff --git a/authentication/views.py b/authentication/views.py index f3e291df..d6df0539 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -11,6 +11,7 @@ from durin.models import AuthToken from rest_framework import status from rest_framework.decorators import ( + action, api_view, authentication_classes, permission_classes, @@ -38,6 +39,14 @@ User: AUTH_USER_MODEL = get_user_model() +def revoke_other_tokens(user, current_token=None): + # Keep only the provided token for this user; revoke all when current_token is null. + if current_token: + AuthToken.objects.filter(user=user).exclude(pk=current_token.pk).delete() + else: + AuthToken.objects.filter(user=user).delete() + + class PasswordResetRequestView(rest_email_auth.views.PasswordResetRequestView): authentication_classes: list = [] permission_classes: list = [] @@ -168,14 +177,23 @@ def post(request: Request) -> Response: user.set_password(new_password) user.save() - if request.auth: - AuthToken.objects.filter(user=user).exclude(pk=request.auth.pk).delete() - else: - AuthToken.objects.filter(user=user).delete() + revoke_other_tokens(user=user, current_token=request.auth) # Return a success response return Response({"message": "Password changed successfully"}) -TokenSessionsViewSet = durin_views.TokenSessionsViewSet +class TokenSessionsViewSet(durin_views.TokenSessionsViewSet): + @action( + detail=False, + methods=["delete"], + url_path="others", + authentication_classes=[CookieTokenAuthentication], + permission_classes=[IsAuthenticated], + ) + def revoke_others(self, request: Request) -> Response: + revoke_other_tokens(user=request.user, current_token=request.auth) + return Response(status=status.HTTP_204_NO_CONTENT) + + APIAccessTokenView = durin_views.APIAccessTokenView diff --git a/frontend/src/components/me/sessions/SessionList.jsx b/frontend/src/components/me/sessions/SessionList.jsx index 558ed407..54a39486 100644 --- a/frontend/src/components/me/sessions/SessionList.jsx +++ b/frontend/src/components/me/sessions/SessionList.jsx @@ -1,14 +1,16 @@ import React from "react"; import { Row, Col, Badge } from "reactstrap"; import { VscDebugDisconnect } from "react-icons/vsc"; +import { MdOutlineDevicesOther } from "react-icons/md"; import { IconButton, DateHoverable, useAxiosComponentLoader, + confirm, } from "@certego/certego-ui"; -import { deleteTokenById } from "./api"; +import { deleteOtherSessions, deleteTokenById } from "./api"; import { SESSIONS_BASE_URI } from "../../../constants/api"; export default function SessionsList() { @@ -33,6 +35,23 @@ export default function SessionsList() { // callbacks const revokeSessionCb = React.useCallback( async (id, clientName) => { + const answer = await confirm({ + message: ( +
+

+ Note: This is an irreversible operation. +

+

+ This will revoke the selected session for device: + {clientName}. +

+ Are you sure you wish to proceed? +
+ ), + confirmText: "Yes", + }); + if (!answer) return; + try { await deleteTokenById(id, clientName); // reload after 500ms @@ -44,72 +63,109 @@ export default function SessionsList() { [refetch], ); + const revokeOtherSessionsCb = React.useCallback(async () => { + const answer = await confirm({ + message: ( +
+

+ Note: This is an irreversible operation. +

+

This will revoke all sessions except the current one.

+ Are you sure you wish to proceed? +
+ ), + confirmText: "Yes", + }); + if (!answer) return; + + try { + await deleteOtherSessions(); + // reload after 500ms + setTimeout(refetch, 500); + } catch (e) { + // handled inside deleteOtherSessions + } + }, [refetch]); + return ( ( -
    - {tokenSessions.map( - ({ - id, - client, - created, - expiry, - has_expired: hasExpired, - is_current: isCurrent, - }) => ( -
  1. - - - Device -   - {client} - - - Created - - - - Expires - - {hasExpired && ( - - expired - - )} - - {/* Actions */} - - {!isCurrent ? ( - revokeSessionCb(id, client)} + <> +
    + +
    +
      + {tokenSessions.map( + ({ + id, + client, + created, + expiry, + has_expired: hasExpired, + is_current: isCurrent, + }) => ( +
    1. + + + Device +   + {client} + + + Created + + + + Expires + - ) : ( - current - )} - - -
    2. - ), - )} -
    + {hasExpired && ( + + expired + + )} + + {/* Actions */} + + {!isCurrent ? ( + revokeSessionCb(id, client)} + /> + ) : ( + current + )} + +
    +
  2. + ), + )} +
+ )} /> ); diff --git a/frontend/src/components/me/sessions/api.js b/frontend/src/components/me/sessions/api.js index f0da8698..f4b1a20f 100644 --- a/frontend/src/components/me/sessions/api.js +++ b/frontend/src/components/me/sessions/api.js @@ -12,7 +12,12 @@ async function createNewToken() { addToast("Generated new API key for you!", null, "success", true); return resp; } catch (e) { - addToast("Failed!", e.parsedMsg.toString(), "danger", true); + addToast( + "Failed!", + e?.parsedMsg?.toString?.() || e?.message || "Unknown error", + "danger", + true, + ); return Promise.reject(e); } } @@ -23,7 +28,12 @@ async function deleteToken() { addToast("API key was deleted!", null, "success", true); return resp; } catch (e) { - addToast("Failed!", e.parsedMsg.toString(), "danger", true); + addToast( + "Failed!", + e?.parsedMsg?.toString?.() || e?.message || "Unknown error", + "danger", + true, + ); return Promise.reject(e); } } @@ -36,9 +46,30 @@ async function deleteTokenById(id, clientName) { addToast(`Revoked Session (${clientName}).`, null, "success", true, 6000); return resp; } catch (e) { - addToast("Failed!", e.parsedMsg.toString(), "danger", true); + addToast( + "Failed!", + e?.parsedMsg?.toString?.() || e?.message || "Unknown error", + "danger", + true, + ); return Promise.reject(e); } } -export { createNewToken, deleteToken, deleteTokenById }; +async function deleteOtherSessions() { + try { + const resp = await axios.delete(`${SESSIONS_BASE_URI}/others`); + addToast("Revoked all other sessions.", null, "success", true, 6000); + return resp; + } catch (e) { + addToast( + "Failed!", + e?.parsedMsg?.toString?.() || e?.message || "Unknown error", + "danger", + true, + ); + return Promise.reject(e); + } +} + +export { createNewToken, deleteToken, deleteTokenById, deleteOtherSessions }; diff --git a/frontend/tests/components/me/sessions/SessionList.test.jsx b/frontend/tests/components/me/sessions/SessionList.test.jsx new file mode 100644 index 00000000..a1c323b1 --- /dev/null +++ b/frontend/tests/components/me/sessions/SessionList.test.jsx @@ -0,0 +1,164 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import SessionsList from "../../../../src/components/me/sessions/SessionList"; +import { + deleteOtherSessions, + deleteTokenById, +} from "../../../../src/components/me/sessions/api"; +import { confirm, useAxiosComponentLoader } from "@certego/certego-ui"; + +vi.mock("../../../../src/components/me/sessions/api", () => ({ + deleteOtherSessions: vi.fn(), + deleteTokenById: vi.fn(), +})); + +const refetchMock = vi.fn(); + +vi.mock("@certego/certego-ui", async () => { + const actual = await vi.importActual("@certego/certego-ui"); + return { + ...actual, + confirm: vi.fn(), + IconButton: ({ id, title, onClick }) => ( + + ), + DateHoverable: ({ id }) => , + useAxiosComponentLoader: vi.fn(), + }; +}); + +describe("SessionsList", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + const unsortedSessions = [ + { + id: 2, + client: "Firefox", + created: 200, + expiry: 300, + has_expired: false, + is_current: false, + }, + { + id: 1, + client: "Current Browser", + created: 100, + expiry: 400, + has_expired: false, + is_current: true, + }, + { + id: 3, + client: "Safari", + created: 300, + expiry: 500, + has_expired: false, + is_current: false, + }, + ]; + + useAxiosComponentLoader.mockImplementation((_, transformer) => { + const tokenSessions = transformer(unsortedSessions); + const Loader = ({ render: renderFn }) => renderFn(); + return [tokenSessions, Loader, refetchMock]; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("renders sessions with current one first and hides revoke button for current session", () => { + render(); + + const deviceLabels = screen + .getAllByText(/Current Browser|Firefox|Safari/) + .map((el) => el.textContent.replace(/^Device\s*/i, "").trim()); + expect(deviceLabels).toEqual(["Current Browser", "Safari", "Firefox"]); + + expect(screen.getByText("current")).toBeInTheDocument(); + expect( + screen.queryByTestId("sessionslist-1__revoke-btn"), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId("sessionslist-2__revoke-btn"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("sessionslist-3__revoke-btn"), + ).toBeInTheDocument(); + }); + + test("revoke other sessions is cancelled when confirmation is rejected", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + confirm.mockResolvedValue(false); + + render(); + + await user.click(screen.getByTestId("sessionslist__revoke-others-btn")); + + expect(confirm).toHaveBeenCalled(); + expect(deleteOtherSessions).not.toHaveBeenCalled(); + expect(refetchMock).not.toHaveBeenCalled(); + }); + + test("revoke other sessions calls API and triggers delayed refetch", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + confirm.mockResolvedValue(true); + deleteOtherSessions.mockResolvedValue({}); + + render(); + + await user.click(screen.getByTestId("sessionslist__revoke-others-btn")); + + await waitFor(() => { + expect(deleteOtherSessions).toHaveBeenCalledTimes(1); + }); + + expect(refetchMock).not.toHaveBeenCalled(); + vi.advanceTimersByTime(500); + expect(refetchMock).toHaveBeenCalledTimes(1); + }); + + test("revoke single session is cancelled when confirmation is rejected", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + confirm.mockResolvedValue(false); + + render(); + + await user.click(screen.getByTestId("sessionslist-2__revoke-btn")); + + expect(confirm).toHaveBeenCalled(); + expect(deleteTokenById).not.toHaveBeenCalled(); + vi.advanceTimersByTime(500); + expect(refetchMock).not.toHaveBeenCalled(); + }); + + test("revoke single session passes id and client name then triggers delayed refetch", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + confirm.mockResolvedValue(true); + deleteTokenById.mockResolvedValue({}); + + render(); + + await user.click(screen.getByTestId("sessionslist-2__revoke-btn")); + + await waitFor(() => { + expect(deleteTokenById).toHaveBeenCalledWith(2, "Firefox"); + }); + + vi.advanceTimersByTime(500); + expect(refetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/tests/components/me/sessions/api.test.js b/frontend/tests/components/me/sessions/api.test.js new file mode 100644 index 00000000..35b50934 --- /dev/null +++ b/frontend/tests/components/me/sessions/api.test.js @@ -0,0 +1,77 @@ +import axios from "axios"; +import { addToast } from "@certego/certego-ui"; + +import { + deleteOtherSessions, + deleteTokenById, +} from "../../../../src/components/me/sessions/api"; +import { SESSIONS_BASE_URI } from "../../../../src/constants/api"; + +vi.mock("axios"); +vi.mock("@certego/certego-ui", () => ({ + addToast: vi.fn(), +})); + +describe("sessions api helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("deleteTokenById calls endpoint and shows success toast", async () => { + axios.delete.mockResolvedValue({ status: 204 }); + + await deleteTokenById(42, "Firefox"); + + expect(axios.delete).toHaveBeenCalledWith(`${SESSIONS_BASE_URI}/42`); + expect(addToast).toHaveBeenCalledWith( + "Revoked Session (Firefox).", + null, + "success", + true, + 6000, + ); + }); + + test("deleteTokenById rejects and shows failure toast", async () => { + const error = { parsedMsg: "cannot revoke" }; + axios.delete.mockRejectedValue(error); + + await expect(deleteTokenById(9, "Safari")).rejects.toEqual(error); + + expect(addToast).toHaveBeenCalledWith( + "Failed!", + "cannot revoke", + "danger", + true, + ); + }); + + test("deleteOtherSessions calls /others endpoint and shows success toast", async () => { + axios.delete.mockResolvedValue({ status: 204 }); + + await deleteOtherSessions(); + + expect(axios.delete).toHaveBeenCalledWith(`${SESSIONS_BASE_URI}/others`); + expect(addToast).toHaveBeenCalledWith( + "Revoked all other sessions.", + null, + "success", + true, + 6000, + ); + }); + + test("deleteOtherSessions rejects and shows failure toast", async () => { + const error = { parsedMsg: "backend error" }; + axios.delete.mockRejectedValue(error); + + await expect(deleteOtherSessions()).rejects.toEqual(error); + + expect(addToast).toHaveBeenCalledWith( + "Failed!", + "backend error", + "danger", + true, + ); + }); +}); diff --git a/tests/authentication/test_auth.py b/tests/authentication/test_auth.py index f8e963d9..721e1314 100644 --- a/tests/authentication/test_auth.py +++ b/tests/authentication/test_auth.py @@ -17,6 +17,7 @@ request_pwd_reset_uri = reverse("auth_request-password-reset") reset_pwd_uri = reverse("auth_reset-password") change_password_uri = reverse("auth_change-password") +revoke_other_sessions_uri = reverse("auth_tokensessions-revoke-others") @tag("api", "user") @@ -458,3 +459,41 @@ def test_change_password_unauthenticated_401(self): response = self.client.post(change_password_uri, body) self.assertIn(response.status_code, [401, 403]) + + +@tag("api", "user") +class RevokeOtherSessionsTestCase(CustomOAuthTestCase): + def tearDown(self): + self.client.credentials() + AuthToken.objects.all().delete() + Client.objects.all().delete() + + def test_revoke_other_sessions_204(self): + current_token = AuthToken.objects.create( + user=self.user, + client=Client.objects.create(name="test_revoke_others_current"), + ) + AuthToken.objects.create( + user=self.user, + client=Client.objects.create(name="test_revoke_others_other_1"), + ) + AuthToken.objects.create( + user=self.user, + client=Client.objects.create(name="test_revoke_others_other_2"), + ) + self.assertEqual(AuthToken.objects.filter(user=self.user).count(), 3) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {current_token.token}") + response = self.client.delete(revoke_other_sessions_uri) + + self.assertEqual(response.status_code, 204, msg=response) + remaining_tokens = AuthToken.objects.filter(user=self.user) + self.assertEqual(remaining_tokens.count(), 1) + self.assertTrue(remaining_tokens.filter(pk=current_token.pk).exists()) + + def test_revoke_other_sessions_unauthenticated_401(self): + self.client.credentials() + + response = self.client.delete(revoke_other_sessions_uri) + + self.assertIn(response.status_code, [401, 403]) From ec99a5bdfafe2c882f270341e38e5229f69a2be7 Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Wed, 1 Apr 2026 16:29:48 +0545 Subject: [PATCH 109/109] Enh: Add sources tracking to Credential and link IPs on credential creation. Closes #1098 (#1124) * Add sources tracking to Credential and link IPs on credential creation Signed-off-by: Drona Raj Gyawali * refactor code --------- Signed-off-by: Drona Raj Gyawali --- .../cronjobs/repositories/cowrie_session.py | 2 + .../migrations/0047_credential_sources.py | 18 ++++++ greedybear/models.py | 5 ++ tests/test_cowrie_session_repository.py | 55 +++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 greedybear/migrations/0047_credential_sources.py diff --git a/greedybear/cronjobs/repositories/cowrie_session.py b/greedybear/cronjobs/repositories/cowrie_session.py index 6fb14029..d454287f 100644 --- a/greedybear/cronjobs/repositories/cowrie_session.py +++ b/greedybear/cronjobs/repositories/cowrie_session.py @@ -180,3 +180,5 @@ def add_credential( protocol=normalized_protocol, ) session.credentials.add(credential) + credential.sources.add(session.source) + self.log.debug(f"linked source {session.source.name} to credential '{credential}'") diff --git a/greedybear/migrations/0047_credential_sources.py b/greedybear/migrations/0047_credential_sources.py new file mode 100644 index 00000000..bcbc2cd0 --- /dev/null +++ b/greedybear/migrations/0047_credential_sources.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-26 08:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0046_sensor_label'), + ] + + operations = [ + migrations.AddField( + model_name='credential', + name='sources', + field=models.ManyToManyField(blank=True, related_name='credentials', to='greedybear.ioc'), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 685e4529..9644e700 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -136,6 +136,11 @@ class Credential(models.Model): username = models.CharField(max_length=256, blank=False) password = models.CharField(max_length=256, blank=False) protocol = models.CharField(max_length=32, blank=True, default="") + sources = models.ManyToManyField( + "IOC", + blank=True, + related_name="credentials", + ) class Meta: constraints = [ diff --git a/tests/test_cowrie_session_repository.py b/tests/test_cowrie_session_repository.py index 4f10b3dd..4d698b17 100644 --- a/tests/test_cowrie_session_repository.py +++ b/tests/test_cowrie_session_repository.py @@ -241,3 +241,58 @@ def test_delete_sessions_without_commands(self): self.assertEqual(deleted_count, 1) self.assertFalse(CowrieSession.objects.filter(session_id=777).exists()) self.assertTrue(CowrieSession.objects.filter(session_id=888).exists()) + + +class CredentialReuseTestCase(CustomTestCase): + def setUp(self): + self.repo = CowrieSessionRepository() + # counter for generating unique session_ids + self._session_counter = 1000 + + def create_cowrie_session(self, source_ip: str, session_id=None): + """Helper to create a CowrieSession with a unique session_id.""" + source_ioc, _ = IOC.objects.get_or_create(name=source_ip, defaults={"type": "ip"}) + if session_id is None: + session_id = self._session_counter + self._session_counter += 1 + session = CowrieSession.objects.create( + session_id=session_id, + source=source_ioc, + ) + return session + + def test_same_ip_not_counted_twice(self): + session = self.create_cowrie_session(source_ip="1.2.3.4", session_id=111) + + self.repo.add_credential(session, "admin", "123") + self.repo.add_credential(session, "admin", "123") + + credential = Credential.objects.get(username="admin", password="123") + self.assertEqual(credential.sources.count(), 1) + + def test_different_ips_counted(self): + session1 = self.create_cowrie_session(source_ip="1.2.3.4", session_id=111) + session2 = self.create_cowrie_session(source_ip="5.6.7.8", session_id=222) + + self.repo.add_credential(session1, "admin", "123") + self.repo.add_credential(session2, "admin", "123") + + credential = Credential.objects.get(username="admin", password="123") + self.assertEqual(credential.sources.count(), 2) + + def test_same_source_ip_different_sessions_not_counted_twice(self): + """ + Same credential added from two different sessions + but the same source IP should only be linked once. + """ + # two DIFFERENT sessions, but SAME source IP + session1 = self.create_cowrie_session(source_ip="1.2.3.4", session_id=333) + session2 = self.create_cowrie_session(source_ip="1.2.3.4", session_id=444) + + self.repo.add_credential(session1, "admin", "123") + self.repo.add_credential(session2, "admin", "123") + + credential = Credential.objects.get(username="admin", password="123") + + # despite two different sessions, same IP = count only 1 + self.assertEqual(credential.sources.count(), 1)