diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 98cbcaa6..52d5c38b 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1 @@
open_collective: intelowl-project
-github: intelowlproject
\ No newline at end of file
diff --git a/README.md b/README.md
index b1452bf9..dbdd1822 100644
--- a/README.md
+++ b/README.md
@@ -54,10 +54,12 @@ To install it locally, Please refer to our [installation guide](https://intelowl
Thanks to [The Honeynet Project](https://www.honeynet.org) we are providing free public feeds available [here](https://greedybear.honeynet.org).
-#### DigitalOcean
+#### Google Summer of Code
+
-In 2022 we joined the official [DigitalOcean Open Source Program](https://www.digitalocean.com/open-source?utm_medium=opensource&utm_source=IntelOwl).
+In 2026 we started participating to the [Google Summer of Code](https://summerofcode.withgoogle.com/) (GSoC)!
+If you are interested in participating in the next Google Summer of Code, check all the info available in the [dedicated repository](https://github.com/intelowlproject/gsoc)!
## Maintainers and Key Contributors
diff --git a/api/serializers.py b/api/serializers.py
index aba8d0b1..f11298b6 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -6,15 +6,15 @@
from rest_framework import serializers
from greedybear.consts import REGEX_DOMAIN
-from greedybear.models import IOC, GeneralHoneypot, Sensor, Tag
+from greedybear.models import IOC, Honeypot, Sensor, Tag
from greedybear.utils import is_ip_address
logger = logging.getLogger(__name__)
-class GeneralHoneypotSerializer(serializers.ModelSerializer):
+class HoneypotSerializer(serializers.ModelSerializer):
class Meta:
- model = GeneralHoneypot
+ model = Honeypot
def to_representation(self, value):
return value.name
@@ -33,7 +33,7 @@ class Meta:
class IOCSerializer(serializers.ModelSerializer):
- general_honeypot = GeneralHoneypotSerializer(many=True, read_only=True)
+ general_honeypot = HoneypotSerializer(many=True, read_only=True, source="honeypots")
tags = TagSerializer(many=True, read_only=True)
sensors = SensorSerializer(many=True, read_only=True)
diff --git a/api/views/__init__.py b/api/views/__init__.py
index ebc2b1f2..398982a0 100644
--- a/api/views/__init__.py
+++ b/api/views/__init__.py
@@ -2,7 +2,7 @@
from api.views.cowrie_session import *
from api.views.enrichment import *
from api.views.feeds import *
-from api.views.general_honeypot import *
from api.views.health import *
+from api.views.honeypots import *
from api.views.news import *
from api.views.statistics import *
diff --git a/api/views/health.py b/api/views/health.py
index ac3f2533..e6ef70c0 100644
--- a/api/views/health.py
+++ b/api/views/health.py
@@ -15,7 +15,7 @@
IOC,
CowrieSession,
FireHolList,
- GeneralHoneypot,
+ Honeypot,
MassScanner,
TorExitNode,
)
@@ -71,8 +71,8 @@ def get_observables_overview(last_24h):
)
honeypot_stats = {
- "total": GeneralHoneypot.objects.count(),
- "active": GeneralHoneypot.objects.filter(active=True).count(),
+ "total": Honeypot.objects.count(),
+ "active": Honeypot.objects.filter(active=True).count(),
}
threat_list_stats = {
diff --git a/api/views/general_honeypot.py b/api/views/honeypots.py
similarity index 62%
rename from api/views/general_honeypot.py
rename to api/views/honeypots.py
index 7679eb04..ea2d5148 100644
--- a/api/views/general_honeypot.py
+++ b/api/views/honeypots.py
@@ -6,7 +6,7 @@
from rest_framework.response import Response
from greedybear.consts import GET
-from greedybear.models import GeneralHoneypot
+from greedybear.models import Honeypot
logger = logging.getLogger(__name__)
@@ -23,14 +23,14 @@ def general_honeypot_list(request):
Response: A JSON response containing the list of general honeypots.
"""
- logger.info(f"Requested general honeypots list from {request.user}.")
+ logger.info(f"Requested honeypots list from {request.user}.")
active = request.query_params.get("onlyActive")
honeypots = []
- general_honeypots = GeneralHoneypot.objects.all()
+ honeypot_objs = Honeypot.objects.all()
if active == "true":
- general_honeypots = general_honeypots.filter(active=True)
- logger.info(f"Requested only active general honeypots from {request.user}")
- honeypots.extend([hp.name for hp in general_honeypots])
+ honeypot_objs = honeypot_objs.filter(active=True)
+ logger.info(f"Requested only active honeypots from {request.user}")
+ honeypots.extend([hp.name for hp in honeypot_objs])
- logger.info(f"General honeypots: {honeypots} given back to user {request.user}")
+ logger.info(f"Honeypots: {honeypots} given back to user {request.user}")
return Response(honeypots)
diff --git a/api/views/statistics.py b/api/views/statistics.py
index adea971b..d4bd1f75 100644
--- a/api/views/statistics.py
+++ b/api/views/statistics.py
@@ -10,7 +10,7 @@
from rest_framework.decorators import action
from rest_framework.response import Response
-from greedybear.models import IOC, GeneralHoneypot, Statistics, ViewType
+from greedybear.models import IOC, Honeypot, Statistics, ViewType
logger = logging.getLogger(__name__)
@@ -92,7 +92,7 @@ def countries(self, request):
qs = (
IOC.objects.filter(last_seen__gte=delta)
.exclude(attacker_country="")
- .filter(general_honeypot__active=True)
+ .filter(honeypots__active=True)
.values("attacker_country")
.annotate(count=Count("id", distinct=True))
.order_by("-count")
@@ -103,7 +103,7 @@ def countries(self, request):
@action(detail=False, methods=["get"])
def feeds_types(self, request):
"""
- Retrieve statistics for different types of feeds using GeneralHoneypot M2M relationship.
+ Retrieve statistics for different types of feeds using Honeypot M2M relationship.
Args:
request: The incoming request object.
@@ -113,10 +113,10 @@ def feeds_types(self, request):
"""
# Build annotations for each active general honeypot
annotations = {}
- general_honeypots = GeneralHoneypot.objects.all().filter(active=True)
- for hp in general_honeypots:
+ honeypots = Honeypot.objects.all().filter(active=True)
+ for hp in honeypots:
# Use M2M relationship instead of boolean fields
- annotations[hp.name] = Count("name", distinct=True, filter=Q(general_honeypot__name__iexact=hp.name))
+ annotations[hp.name] = Count("name", distinct=True, filter=Q(honeypots__name__iexact=hp.name))
return self.__aggregation_response_static_ioc(annotations)
def __aggregation_response_static_statistics(self, annotations: dict) -> Response:
@@ -147,7 +147,7 @@ def __aggregation_response_static_ioc(self, annotations: dict) -> Response:
qs = (
IOC.objects.filter(last_seen__gte=delta)
- .exclude(general_honeypot__active=False)
+ .exclude(honeypots__active=False)
.annotate(date=Trunc("last_seen", basis))
.values("date")
.annotate(**annotations)
diff --git a/api/views/utils.py b/api/views/utils.py
index 349e60e8..109d515c 100644
--- a/api/views/utils.py
+++ b/api/views/utils.py
@@ -1,14 +1,16 @@
# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear
# See the file 'LICENSE' for copying permission.
import csv
+import hashlib
import logging
+import urllib.parse
from datetime import datetime, timedelta
import feedparser
import requests
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
-from django.core.cache import cache
+from django.core.cache import cache, caches
from django.db.models import Count, F, Max, Min, Q, Sum, Value
from django.db.models.functions import JSONObject
from django.http import HttpResponse, HttpResponseBadRequest, StreamingHttpResponse
@@ -19,7 +21,7 @@
from api.serializers import FeedsRequestSerializer, parse_feed_types
from greedybear.consts import CACHE_KEY_GREEDYBEAR_NEWS, CACHE_TIMEOUT_SECONDS, RSS_FEED_URL
from greedybear.enums import IpReputation
-from greedybear.models import IOC, GeneralHoneypot, Statistics
+from greedybear.models import IOC, Honeypot, Statistics
from greedybear.utils import is_ip_address, is_valid_domain
logger = logging.getLogger(__name__)
@@ -144,8 +146,8 @@ def get_valid_feed_types() -> frozenset[str]:
Returns:
frozenset[str]: An immutable set of valid feed type strings
"""
- general_honeypots = GeneralHoneypot.objects.filter(active=True)
- feed_types = ["all"] + [hp.name.lower() for hp in general_honeypots]
+ honeypots = Honeypot.objects.filter(active=True)
+ feed_types = ["all"] + [hp.name.lower() for hp in honeypots]
return frozenset(feed_types)
@@ -226,13 +228,13 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se
if "all" not in feed_params.feed_types:
type_filter = Q()
for ft in feed_params.feed_types:
- type_filter |= Q(general_honeypot__name__iexact=ft)
+ 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(general_honeypot__active=True)
- iocs = iocs.annotate(honeypots=ArrayAgg("general_honeypot__name", distinct=True))
+ 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":
@@ -315,7 +317,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
"login_attempts",
"recurrence_probability",
"expected_interactions",
- "honeypots", # used to build feed_type; removed from response
+ "honeypot_names", # used to build feed_type; removed from response
"destination_ports", # used to calculate destination_port_count
"attacker_country",
"autonomous_system",
@@ -344,7 +346,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
else:
iocs_iter = iocs.values(*required_fields).iterator(chunk_size=2000)
for ioc in iocs_iter:
- ioc_feed_type = [hp.lower() for hp in ioc.get("honeypots", []) if hp]
+ 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"),
@@ -358,7 +360,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
if not verbose:
data_.pop("destination_ports", None)
data_.pop("autonomous_system", None)
- data_.pop("honeypots", None)
+ data_.pop("honeypot_names", None)
data_.pop("id", None)
json_list.append(data_)
@@ -386,7 +388,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
"first_seen",
"last_seen",
"recurrence_probability",
- "honeypots",
+ "honeypot_names",
"ip_reputation",
}
# Fetch fields from database
@@ -416,7 +418,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
confidence = 90
# Labels
- labels = [hp.lower() for hp in ioc.get("honeypots", []) if hp]
+ labels = [hp.lower() for hp in ioc.get("honeypot_names", []) if hp]
if ioc.get("ip_reputation"):
labels.append(ioc["ip_reputation"])
@@ -446,15 +448,33 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
def asn_aggregated_queryset(iocs_qs, request, feed_params):
"""
- Perform DB-level aggregation grouped by ASN.
+ Retrieve ASN aggregation data. Caches the heavy aggregation query
+ since the data only updates during the extraction cronjob.
Args
iocs_qs (QuerySet): Filtered IOC queryset from get_queryset;
request (Request): The API request object;
feed_params (FeedRequestParams): Validated parameter object
- Returns: A values-grouped queryset with annotated metrics and honeypot arrays.
+ Returns: A list of dicts with aggregated metrics and honeypot arrays per ASN.
"""
+
+ # 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}"
+
+ cached_result = shared_cache.get(cache_key)
+ if cached_result is not None:
+ return cached_result
+
asn_filter = request.query_params.get("asn")
if asn_filter:
iocs_qs = iocs_qs.filter(autonomous_system__asn=asn_filter)
@@ -480,24 +500,25 @@ 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(general_honeypot__active=True)
+ .filter(honeypots__active=True)
.values(asn=F("autonomous_system__asn"))
.annotate(
- honeypots=ArrayAgg(
- "general_honeypot__name",
+ honeypot_names=ArrayAgg(
+ "honeypots__name",
distinct=True,
)
)
)
- hp_lookup = {row["asn"]: row["honeypots"] or [] for row in honeypot_agg}
+ hp_lookup = {row["asn"]: row["honeypot_names"] or [] for row in honeypot_agg}
- # merging numeric aggregate with honeypot names for each asn
result = []
for row in numeric_agg:
asn = row["asn"]
@@ -505,6 +526,9 @@ def asn_aggregated_queryset(iocs_qs, request, feed_params):
row_dict["honeypots"] = sorted(hp_lookup.get(asn, []))
result.append(row_dict)
+ # Set cache with a 60-minute timeout (max extraction interval length) to prevent memory bloat
+ shared_cache.set(cache_key, result, timeout=3600)
+
return result
diff --git a/configuration/gunicorn/config.py b/configuration/gunicorn/config.py
index 6d001381..d5765f82 100644
--- a/configuration/gunicorn/config.py
+++ b/configuration/gunicorn/config.py
@@ -1,10 +1,10 @@
-import multiprocessing
+import os
# Server socket
bind = "unix:/run/gunicorn/main.sock"
# Worker processes
-workers = 2 * multiprocessing.cpu_count() + 1
+workers = 2 * len(os.sched_getaffinity(0)) + 1
max_requests = 1000
max_requests_jitter = 50
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 36775739..8e5839d3 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -37,22 +37,30 @@ ENV UV_PROJECT_ENVIRONMENT=/usr/local
WORKDIR $APP_ROOT
-# Install runtime dependencies
-# - libgomp1 is required for model training
-# - curl is used for healthcheck
-RUN apt-get update && apt-get install -y --no-install-recommends \
- libgomp1 curl gosu \
+# Layer 1: stable runtime OS deps — cached across pyproject.toml/uv.lock changes.
+# libgomp1: model training; curl: healthcheck; gosu: entrypoint privilege drop
+# libpq5: runtime shared library required by the psycopg[c] C extension
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libgomp1 curl gosu libpq5 \
&& rm -rf /var/lib/apt/lists/*
-# Install python packages
+# Layer 2: Python packages — only re-runs when pyproject.toml/uv.lock change.
+# Build-only deps (gcc, python3-dev, libpq-dev) compile the psycopg[c] C
+# extension and are purged in the same layer to keep the final image lean.
COPY pyproject.toml uv.lock ./
-RUN uv sync --no-dev --locked
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends gcc python3-dev libpq-dev \
+ && uv sync --no-dev --locked \
+ && uv cache clean \
+ && apt-get purge -y gcc python3-dev libpq-dev \
+ && apt-get autoremove -y \
+ && rm -rf /var/lib/apt/lists/*
# Copy files
COPY . $APP_ROOT
COPY --from=frontend-build /app/build /var/www/reactapp
-# separation is required to avoid to re-execute os installation in case of change of python requirements
+# Set up log directories, fix permissions, and remove frontend source (served from /var/www/reactapp)
RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \
&& touch ${LOG_PATH}/django/api.log ${LOG_PATH}/django/api_errors.log \
&& touch ${LOG_PATH}/django/greedybear.log ${LOG_PATH}/django/greedybear_errors.log \
@@ -60,7 +68,6 @@ RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \
&& 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 \
- && usermod -u 2000 www-data \
&& chown -R www-data:www-data ${LOG_PATH} /opt/deploy/ ${APP_ROOT}/mlmodels/ \
&& rm -rf frontend/
diff --git a/docker/default.yml b/docker/default.yml
index c7c2600d..699d4abf 100644
--- a/docker/default.yml
+++ b/docker/default.yml
@@ -71,6 +71,8 @@ services:
container_name: greedybear_qcluster
restart: unless-stopped
stop_grace_period: 3m
+ entrypoint:
+ - ./docker/entrypoint_qcluster.sh
command: sh -c "python manage.py setup_schedules && exec python manage.py qcluster"
volumes:
- generic_logs:/var/log/greedybear
@@ -82,7 +84,6 @@ services:
condition: service_healthy
app:
condition: service_healthy
- user: "2000:82"
healthcheck:
disable: true
diff --git a/docker/entrypoint_gunicorn.sh b/docker/entrypoint_gunicorn.sh
index 1d71e4c4..3de924bd 100755
--- a/docker/entrypoint_gunicorn.sh
+++ b/docker/entrypoint_gunicorn.sh
@@ -20,8 +20,9 @@ python manage.py collectstatic --noinput --clear --verbosity 0
mkdir -p /var/log/greedybear/gunicorn
mkdir -p /run/gunicorn
-# Fix log file ownership (manage.py commands above run as root and may create new log files)
-chown -R 2000:82 /var/log/greedybear /run/gunicorn
+# 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
# Obtain the current GreedyBear version number
GREEDYBEAR_VERSION=$(uv version --short)
diff --git a/docker/entrypoint_qcluster.sh b/docker/entrypoint_qcluster.sh
new file mode 100755
index 00000000..965a9421
--- /dev/null
+++ b/docker/entrypoint_qcluster.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+# Fix mlmodels ownership (volumes may retain files owned by a previous UID)
+chown -R www-data:www-data /opt/deploy/greedybear/mlmodels
+
+if [ "$DJANGO_TEST_SERVER" = "True" ]; then
+ # Dev mode: run as root (needed for hot-reload on volume-mounted source)
+ exec "$@"
+else
+ # Production mode: drop privileges to www-data before starting qcluster
+ exec gosu www-data "$@"
+fi
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9bfe1abb..f6a5744a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -23,7 +23,7 @@
"react-use": "^17.6.0",
"reactstrap": "^9.2.3",
"recharts": "^2.15.4",
- "sass": "^1.98.0",
+ "sass": "^1.99.0",
"zustand": "^4.5.7"
},
"devDependencies": {
@@ -31,14 +31,14 @@
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^6.0.1",
- "@vitest/coverage-v8": "^4.1.2",
+ "@vitest/coverage-v8": "^4.1.3",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.0",
- "jsdom": "^29.0.1",
+ "jsdom": "^29.0.2",
"prettier": "^3.8.1",
"stylelint": "^17.6.0",
"vite": "^8.0.3",
@@ -52,41 +52,30 @@
"dev": true
},
"node_modules/@asamuzakjp/css-color": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
- "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
+ "version": "5.1.8",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz",
+ "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==",
"dev": true,
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0",
- "lru-cache": "^11.2.6"
+ "@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
- "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
- "version": "11.2.7",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
- "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
- "dev": true,
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@asamuzakjp/dom-selector": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
- "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
+ "version": "7.0.8",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.8.tgz",
+ "integrity": "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==",
"dev": true,
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.2.7"
+ "is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
@@ -105,15 +94,6 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
- "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
- "version": "11.2.7",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
- "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
- "dev": true,
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@@ -1962,13 +1942,13 @@
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz",
- "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.3.tgz",
+ "integrity": "sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==",
"dev": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.2",
+ "@vitest/utils": "4.1.3",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -1982,8 +1962,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.1.2",
- "vitest": "4.1.2"
+ "@vitest/browser": "4.1.3",
+ "vitest": "4.1.3"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -1992,15 +1972,15 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
- "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz",
+ "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==",
"dev": true,
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.2",
- "@vitest/utils": "4.1.2",
+ "@vitest/spy": "4.1.3",
+ "@vitest/utils": "4.1.3",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2009,12 +1989,12 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
- "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz",
+ "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==",
"dev": true,
"dependencies": {
- "@vitest/spy": "4.1.2",
+ "@vitest/spy": "4.1.3",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2035,9 +2015,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
- "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz",
+ "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==",
"dev": true,
"dependencies": {
"tinyrainbow": "^3.1.0"
@@ -2047,12 +2027,12 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
- "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz",
+ "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==",
"dev": true,
"dependencies": {
- "@vitest/utils": "4.1.2",
+ "@vitest/utils": "4.1.3",
"pathe": "^2.0.3"
},
"funding": {
@@ -2060,13 +2040,13 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
- "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz",
+ "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==",
"dev": true,
"dependencies": {
- "@vitest/pretty-format": "4.1.2",
- "@vitest/utils": "4.1.2",
+ "@vitest/pretty-format": "4.1.3",
+ "@vitest/utils": "4.1.3",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2075,21 +2055,21 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
- "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz",
+ "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==",
"dev": true,
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
- "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz",
+ "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==",
"dev": true,
"dependencies": {
- "@vitest/pretty-format": "4.1.2",
+ "@vitest/pretty-format": "4.1.3",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -5436,13 +5416,13 @@
}
},
"node_modules/jsdom": {
- "version": "29.0.1",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
- "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
+ "version": "29.0.2",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
+ "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"dev": true,
"dependencies": {
- "@asamuzakjp/css-color": "^5.0.1",
- "@asamuzakjp/dom-selector": "^7.0.3",
+ "@asamuzakjp/css-color": "^5.1.5",
+ "@asamuzakjp/dom-selector": "^7.0.6",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
@@ -7438,9 +7418,9 @@
}
},
"node_modules/sass": {
- "version": "1.98.0",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
- "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
+ "version": "1.99.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
+ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.1.5",
@@ -8845,18 +8825,18 @@
}
},
"node_modules/vitest": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
- "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
- "dev": true,
- "dependencies": {
- "@vitest/expect": "4.1.2",
- "@vitest/mocker": "4.1.2",
- "@vitest/pretty-format": "4.1.2",
- "@vitest/runner": "4.1.2",
- "@vitest/snapshot": "4.1.2",
- "@vitest/spy": "4.1.2",
- "@vitest/utils": "4.1.2",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz",
+ "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "4.1.3",
+ "@vitest/mocker": "4.1.3",
+ "@vitest/pretty-format": "4.1.3",
+ "@vitest/runner": "4.1.3",
+ "@vitest/snapshot": "4.1.3",
+ "@vitest/spy": "4.1.3",
+ "@vitest/utils": "4.1.3",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8884,10 +8864,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.2",
- "@vitest/browser-preview": "4.1.2",
- "@vitest/browser-webdriverio": "4.1.2",
- "@vitest/ui": "4.1.2",
+ "@vitest/browser-playwright": "4.1.3",
+ "@vitest/browser-preview": "4.1.3",
+ "@vitest/browser-webdriverio": "4.1.3",
+ "@vitest/coverage-istanbul": "4.1.3",
+ "@vitest/coverage-v8": "4.1.3",
+ "@vitest/ui": "4.1.3",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -8911,6 +8893,12 @@
"@vitest/browser-webdriverio": {
"optional": true
},
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
"@vitest/ui": {
"optional": true
},
@@ -9199,37 +9187,27 @@
"dev": true
},
"@asamuzakjp/css-color": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
- "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
+ "version": "5.1.8",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz",
+ "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==",
"dev": true,
"requires": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0",
- "lru-cache": "^11.2.6"
- },
- "dependencies": {
- "lru-cache": {
- "version": "11.2.7",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
- "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
- "dev": true
- }
+ "@csstools/css-tokenizer": "^4.0.0"
}
},
"@asamuzakjp/dom-selector": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
- "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
+ "version": "7.0.8",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.8.tgz",
+ "integrity": "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==",
"dev": true,
"requires": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.2.7"
+ "is-potential-custom-element-name": "^1.0.1"
},
"dependencies": {
"css-tree": {
@@ -9242,12 +9220,6 @@
"source-map-js": "^1.2.1"
}
},
- "lru-cache": {
- "version": "11.2.7",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
- "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
- "dev": true
- },
"mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@@ -10394,13 +10366,13 @@
}
},
"@vitest/coverage-v8": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz",
- "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.3.tgz",
+ "integrity": "sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==",
"dev": true,
"requires": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.2",
+ "@vitest/utils": "4.1.3",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -10412,74 +10384,74 @@
}
},
"@vitest/expect": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
- "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz",
+ "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==",
"dev": true,
"requires": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.2",
- "@vitest/utils": "4.1.2",
+ "@vitest/spy": "4.1.3",
+ "@vitest/utils": "4.1.3",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
}
},
"@vitest/mocker": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
- "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz",
+ "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==",
"dev": true,
"requires": {
- "@vitest/spy": "4.1.2",
+ "@vitest/spy": "4.1.3",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
}
},
"@vitest/pretty-format": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
- "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz",
+ "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==",
"dev": true,
"requires": {
"tinyrainbow": "^3.1.0"
}
},
"@vitest/runner": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
- "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz",
+ "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==",
"dev": true,
"requires": {
- "@vitest/utils": "4.1.2",
+ "@vitest/utils": "4.1.3",
"pathe": "^2.0.3"
}
},
"@vitest/snapshot": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
- "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz",
+ "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==",
"dev": true,
"requires": {
- "@vitest/pretty-format": "4.1.2",
- "@vitest/utils": "4.1.2",
+ "@vitest/pretty-format": "4.1.3",
+ "@vitest/utils": "4.1.3",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
}
},
"@vitest/spy": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
- "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz",
+ "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==",
"dev": true
},
"@vitest/utils": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
- "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz",
+ "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==",
"dev": true,
"requires": {
- "@vitest/pretty-format": "4.1.2",
+ "@vitest/pretty-format": "4.1.3",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -12883,13 +12855,13 @@
}
},
"jsdom": {
- "version": "29.0.1",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
- "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
+ "version": "29.0.2",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
+ "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"dev": true,
"requires": {
- "@asamuzakjp/css-color": "^5.0.1",
- "@asamuzakjp/dom-selector": "^7.0.3",
+ "@asamuzakjp/css-color": "^5.1.5",
+ "@asamuzakjp/dom-selector": "^7.0.6",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
@@ -14205,9 +14177,9 @@
}
},
"sass": {
- "version": "1.98.0",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
- "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
+ "version": "1.99.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
+ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"requires": {
"@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0",
@@ -15148,18 +15120,18 @@
}
},
"vitest": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
- "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
- "dev": true,
- "requires": {
- "@vitest/expect": "4.1.2",
- "@vitest/mocker": "4.1.2",
- "@vitest/pretty-format": "4.1.2",
- "@vitest/runner": "4.1.2",
- "@vitest/snapshot": "4.1.2",
- "@vitest/spy": "4.1.2",
- "@vitest/utils": "4.1.2",
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz",
+ "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==",
+ "dev": true,
+ "requires": {
+ "@vitest/expect": "4.1.3",
+ "@vitest/mocker": "4.1.3",
+ "@vitest/pretty-format": "4.1.3",
+ "@vitest/runner": "4.1.3",
+ "@vitest/snapshot": "4.1.3",
+ "@vitest/spy": "4.1.3",
+ "@vitest/utils": "4.1.3",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
diff --git a/frontend/package.json b/frontend/package.json
index 67a24135..8d1f84d2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,7 +24,7 @@
"react-use": "^17.6.0",
"reactstrap": "^9.2.3",
"recharts": "^2.15.4",
- "sass": "^1.98.0",
+ "sass": "^1.99.0",
"zustand": "^4.5.7"
},
"scripts": {
@@ -50,14 +50,14 @@
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^6.0.1",
- "@vitest/coverage-v8": "^4.1.2",
+ "@vitest/coverage-v8": "^4.1.3",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.0",
- "jsdom": "^29.0.1",
+ "jsdom": "^29.0.2",
"prettier": "^3.8.1",
"stylelint": "^17.6.0",
"vite": "^8.0.3",
diff --git a/frontend/src/components/auth/Login.jsx b/frontend/src/components/auth/Login.jsx
index 6fd44ac1..b63922fc 100644
--- a/frontend/src/components/auth/Login.jsx
+++ b/frontend/src/components/auth/Login.jsx
@@ -78,7 +78,7 @@ function Login() {
id="access-info"
className="col-12 px-1 text-center"
>
-
+
New users
diff --git a/frontend/src/components/auth/api.js b/frontend/src/components/auth/api.js
index b348b22f..7dacae04 100644
--- a/frontend/src/components/auth/api.js
+++ b/frontend/src/components/auth/api.js
@@ -51,7 +51,7 @@ export async function requestPasswordReset(body) {
return resp;
} catch (err) {
addToast("Failed to send email!", err.parsedMsg, "danger", true);
- return null;
+ return Promise.reject(err);
}
}
diff --git a/frontend/src/components/dashboard/utils/charts.jsx b/frontend/src/components/dashboard/utils/charts.jsx
index 6091cddd..ed49d705 100644
--- a/frontend/src/components/dashboard/utils/charts.jsx
+++ b/frontend/src/components/dashboard/utils/charts.jsx
@@ -139,7 +139,7 @@ export const AttackOriginCountriesChart = React.memo(() => {
const { range } = useTimePickerStore();
const {
- rawData: data,
+ normalizedData: data,
loading,
error,
fetchData,
diff --git a/frontend/src/components/feeds/tableColumns.jsx b/frontend/src/components/feeds/tableColumns.jsx
index a7de0078..3eb7ce21 100644
--- a/frontend/src/components/feeds/tableColumns.jsx
+++ b/frontend/src/components/feeds/tableColumns.jsx
@@ -40,7 +40,7 @@ const feedsTableColumns = [
Array.isArray(value) ? (
{value?.map((val, index) => (
- -
+
-
{val}
))}
diff --git a/frontend/src/components/home/Home.jsx b/frontend/src/components/home/Home.jsx
index 9354f214..d3a4017e 100644
--- a/frontend/src/components/home/Home.jsx
+++ b/frontend/src/components/home/Home.jsx
@@ -19,13 +19,15 @@ function Home() {
<>
{/* BG Image */}
-
- {versionText}
-
+ {versionText && (
+
+ {versionText}
+
+ )}
{/* Content */}
diff --git a/frontend/src/components/me/sessions/APIaccess.jsx b/frontend/src/components/me/sessions/APIaccess.jsx
index d3363756..6b192979 100644
--- a/frontend/src/components/me/sessions/APIaccess.jsx
+++ b/frontend/src/components/me/sessions/APIaccess.jsx
@@ -122,6 +122,7 @@ export default function APIAccess() {
id="toggle-show-apikey-btn"
color="dark"
title={tokenVisible ? "Hide API key" : "Show API Key"}
+ aria-label={tokenVisible ? "Hide API key" : "Show API Key"}
className="ms-2 border border-dark"
Icon={tokenVisible ? MdVisibility : MdVisibilityOff}
onClick={() => setTokenVisible((s) => !s)}
@@ -129,6 +130,7 @@ export default function APIAccess() {
revokeSessionCb(id, client)}
/>
) : (
- current
+ current
)}
diff --git a/frontend/src/layouts/AppFooter.jsx b/frontend/src/layouts/AppFooter.jsx
index 3f4dfd96..67bd06c9 100644
--- a/frontend/src/layouts/AppFooter.jsx
+++ b/frontend/src/layouts/AppFooter.jsx
@@ -36,6 +36,7 @@ function AppFooter() {
target="_blank"
rel="noopener noreferrer"
className="ms-3 text-white text-decoration-none"
+ aria-label="IntelOwl on X"
>
@@ -44,6 +45,7 @@ function AppFooter() {
target="_blank"
rel="noopener noreferrer"
className="ms-3 text-white text-decoration-none"
+ aria-label="IntelOwl on GitHub"
>
@@ -52,6 +54,7 @@ function AppFooter() {
target="_blank"
rel="noopener noreferrer"
className="ms-3 text-white text-decoration-none"
+ aria-label="IntelOwl on LinkedIn"
>
diff --git a/frontend/src/stores/useAttackerCountriesStore.jsx b/frontend/src/stores/useAttackerCountriesStore.jsx
index bf128847..2240c839 100644
--- a/frontend/src/stores/useAttackerCountriesStore.jsx
+++ b/frontend/src/stores/useAttackerCountriesStore.jsx
@@ -1,34 +1,10 @@
import axios from "axios";
import { create } from "zustand";
import { IOC_ATTACKER_COUNTRIES_URI } from "../constants/api";
-
-// Normalise country names from T-Pot geoip to match Natural Earth names used by world-atlas@2. (https://github.com/topojson/world-atlas)
-const NAME_FIXES = {
- "United States": "United States of America",
- "Czech Republic": "Czechia",
- "Ivory Coast": "Côte d'Ivoire",
- "Democratic Republic of the Congo": "Dem. Rep. Congo",
- "Republic of the Congo": "Congo",
- "Bosnia and Herzegovina": "Bosnia and Herz.",
- "Central African Republic": "Central African Rep.",
- "Dominican Republic": "Dominican Rep.",
- "Equatorial Guinea": "Eq. Guinea",
- "South Sudan": "S. Sudan",
- "North Macedonia": "Macedonia",
- Eswatini: "eSwatini",
- "State of Palestine": "Palestine",
- "Western Sahara": "W. Sahara",
- "Solomon Islands": "Solomon Is.",
- "Falkland Islands": "Falkland Is.",
- "French Southern Territories": "Fr. S. Antarctic Lands",
-};
-
-function normalise(name) {
- return NAME_FIXES[name] ?? name;
-}
+import { normalizeCountryName } from "../utils/country";
const useAttackerCountriesStore = create((set, get) => ({
- rawData: [],
+ normalizedData: [],
countryDataMap: {},
maxCount: 0,
loading: false,
@@ -58,17 +34,28 @@ const useAttackerCountriesStore = create((set, get) => ({
signal: controller.signal,
});
- const rawData = Array.isArray(resp?.data) ? resp.data : [];
+ const normalizedData = (Array.isArray(resp?.data) ? resp.data : []).map(
+ (item) => {
+ if (
+ item &&
+ typeof item === "object" &&
+ typeof item.country === "string"
+ ) {
+ return { ...item, country: normalizeCountryName(item.country) };
+ }
+ return item;
+ },
+ );
+
const countryDataMap = {};
let maxCount = 0;
- rawData.forEach((item) => {
+ normalizedData.forEach((item) => {
if (item && typeof item === "object") {
const { country, count } = item;
if (typeof country === "string") {
- const key = normalise(country);
const countNum = Number(count) || 0;
- countryDataMap[key] = countNum;
+ countryDataMap[country] = countNum;
if (countNum > maxCount) maxCount = countNum;
}
}
@@ -76,7 +63,7 @@ const useAttackerCountriesStore = create((set, get) => ({
if (get().currentController === controller) {
set({
- rawData,
+ normalizedData,
countryDataMap,
maxCount,
loading: false,
diff --git a/frontend/src/styles/App.scss b/frontend/src/styles/App.scss
index bb1c1c8d..a4a4a49e 100644
--- a/frontend/src/styles/App.scss
+++ b/frontend/src/styles/App.scss
@@ -157,3 +157,20 @@ section.fixed-bottom {
border-color: $darker;
}
}
+
+$dashboard-btn-secondary-bg: #3f7d95;
+$dashboard-btn-secondary-bg-hover: #376d82;
+
+#Dashboard {
+ .btn.btn-secondary {
+ background-color: $dashboard-btn-secondary-bg;
+ border-color: $dashboard-btn-secondary-bg;
+ }
+
+ .btn.btn-secondary:hover,
+ .btn.btn-secondary:focus,
+ .btn.btn-secondary:active {
+ background-color: $dashboard-btn-secondary-bg-hover;
+ border-color: $dashboard-btn-secondary-bg-hover;
+ }
+}
diff --git a/frontend/src/utils/country.js b/frontend/src/utils/country.js
new file mode 100644
index 00000000..0ef67ac4
--- /dev/null
+++ b/frontend/src/utils/country.js
@@ -0,0 +1,33 @@
+/**
+ * Normalise country names from T-Pot geoip to match Natural Earth names used by world-atlas@2.
+ * (https://github.com/topojson/world-atlas)
+ */
+export const COUNTRY_NAME_FIXES = {
+ "United States": "United States of America",
+ "Czech Republic": "Czechia",
+ "Ivory Coast": "Côte d'Ivoire",
+ "Democratic Republic of the Congo": "Dem. Rep. Congo",
+ "Republic of the Congo": "Congo",
+ "Bosnia and Herzegovina": "Bosnia and Herz.",
+ "Central African Republic": "Central African Rep.",
+ "Dominican Republic": "Dominican Rep.",
+ "Equatorial Guinea": "Eq. Guinea",
+ "South Sudan": "S. Sudan",
+ "North Macedonia": "Macedonia",
+ Eswatini: "eSwatini",
+ "State of Palestine": "Palestine",
+ "Western Sahara": "W. Sahara",
+ "Solomon Islands": "Solomon Is.",
+ "Falkland Islands": "Falkland Is.",
+ "French Southern Territories": "Fr. S. Antarctic Lands",
+};
+
+/**
+ * Returns a normalised country name if a fix is available, otherwise returns the original name.
+ *
+ * @param {string|null|undefined} name - Raw country name (e.g., from T-Pot GeoIP)
+ * @returns {string|null|undefined} - Normalised country name (matching Natural Earth standards)
+ */
+export function normalizeCountryName(name) {
+ return COUNTRY_NAME_FIXES[name] ?? name;
+}
diff --git a/frontend/tests/components/auth/utils/EmailForm.test.jsx b/frontend/tests/components/auth/utils/EmailForm.test.jsx
index e2960e0e..598b9d98 100644
--- a/frontend/tests/components/auth/utils/EmailForm.test.jsx
+++ b/frontend/tests/components/auth/utils/EmailForm.test.jsx
@@ -4,9 +4,15 @@ import { render, screen, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import EmailForm from "../../../../src/components/auth/utils/EmailForm";
+import { requestPasswordReset } from "../../../../src/components/auth/api";
+import axios from "axios";
vi.mock("axios");
+vi.mock("@certego/certego-ui", () => ({
+ addToast: vi.fn(),
+}));
+
describe("EmailForm component", () => {
test("Submit email form", async () => {
// mock user interaction: reccomanded to put this at the start of the test
@@ -74,4 +80,34 @@ describe("EmailForm component", () => {
expect(submitButtonElement).not.toBeDisabled();
});
});
+
+ test("Does not trigger onFormSubmit and re-enables button when API request fails", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(axios.post).mockRejectedValueOnce(new Error("Network Error"));
+ const mockOnFormSubmit = vi.fn();
+
+ render(
+
+
+ ,
+ );
+
+ const emailInputElement = screen.getByLabelText("Email Address");
+ const submitButtonElement = screen.getByRole("button", { name: /Send/i });
+
+ await user.type(emailInputElement, "test@test.com");
+ await user.click(submitButtonElement);
+
+ expect(axios.post).toHaveBeenCalledTimes(1);
+
+ await waitFor(() => {
+ expect(submitButtonElement).not.toBeDisabled();
+ });
+
+ expect(mockOnFormSubmit).not.toHaveBeenCalled();
+ });
});
diff --git a/frontend/tests/stores/useAttackerCountriesStore.test.jsx b/frontend/tests/stores/useAttackerCountriesStore.test.jsx
index 37da99bd..e0293269 100644
--- a/frontend/tests/stores/useAttackerCountriesStore.test.jsx
+++ b/frontend/tests/stores/useAttackerCountriesStore.test.jsx
@@ -19,7 +19,7 @@ const createDeferred = () => {
describe("useAttackerCountriesStore", () => {
beforeEach(() => {
useAttackerCountriesStore.setState({
- rawData: [],
+ normalizedData: [],
countryDataMap: {},
maxCount: 0,
loading: false,
@@ -33,7 +33,7 @@ describe("useAttackerCountriesStore", () => {
describe("Initial State", () => {
test("initial state is correct", () => {
const state = useAttackerCountriesStore.getState();
- expect(state.rawData).toEqual([]);
+ expect(state.normalizedData).toEqual([]);
expect(state.countryDataMap).toEqual({});
expect(state.maxCount).toBe(0);
expect(state.loading).toBe(false);
@@ -55,7 +55,10 @@ describe("useAttackerCountriesStore", () => {
await useAttackerCountriesStore.getState().fetchData(mockRange);
const state = useAttackerCountriesStore.getState();
- expect(state.rawData).toEqual(mockData);
+ expect(state.normalizedData).toEqual([
+ { country: "United States of America", count: 100 },
+ { country: "Italy", count: 50 },
+ ]);
expect(state.countryDataMap).toEqual({
"United States of America": 100,
Italy: 50,
@@ -140,7 +143,10 @@ describe("useAttackerCountriesStore", () => {
await Promise.all([fetch1, fetch2]);
expect(axios.get).toHaveBeenCalledTimes(2);
- expect(useAttackerCountriesStore.getState().rawData).toEqual(mockData);
+ expect(useAttackerCountriesStore.getState().normalizedData).toEqual([
+ { country: "United States of America", count: 100 },
+ { country: "Italy", count: 50 },
+ ]);
});
test("sets error state on failure", async () => {
@@ -184,7 +190,10 @@ describe("useAttackerCountriesStore", () => {
// Now loading should be false
expect(useAttackerCountriesStore.getState().loading).toBe(false);
- expect(useAttackerCountriesStore.getState().rawData).toEqual(mockData);
+ expect(useAttackerCountriesStore.getState().normalizedData).toEqual([
+ { country: "United States of America", count: 100 },
+ { country: "Italy", count: 50 },
+ ]);
});
});
});
diff --git a/frontend/tests/utils/country.test.js b/frontend/tests/utils/country.test.js
new file mode 100644
index 00000000..8457b830
--- /dev/null
+++ b/frontend/tests/utils/country.test.js
@@ -0,0 +1,25 @@
+import { describe, it, expect } from "vitest";
+import { normalizeCountryName } from "../../src/utils/country";
+
+describe("normalizeCountryName", () => {
+ it("should normalize known mismatched names", () => {
+ expect(normalizeCountryName("United States")).toBe(
+ "United States of America",
+ );
+ expect(normalizeCountryName("Czech Republic")).toBe("Czechia");
+ expect(normalizeCountryName("Ivory Coast")).toBe("Côte d'Ivoire");
+ expect(normalizeCountryName("South Sudan")).toBe("S. Sudan");
+ });
+
+ it("should return the same name if no mismatch is known", () => {
+ expect(normalizeCountryName("Italy")).toBe("Italy");
+ expect(normalizeCountryName("Brazil")).toBe("Brazil");
+ expect(normalizeCountryName("France")).toBe("France");
+ });
+
+ it("should handle edge cases like empty strings or nulls", () => {
+ expect(normalizeCountryName("")).toBe("");
+ expect(normalizeCountryName(null)).toBe(null);
+ expect(normalizeCountryName(undefined)).toBe(undefined);
+ });
+});
diff --git a/greedybear/admin.py b/greedybear/admin.py
index 2736b1d8..3372630f 100644
--- a/greedybear/admin.py
+++ b/greedybear/admin.py
@@ -12,7 +12,7 @@
CowrieSession,
Credential,
FireHolList,
- GeneralHoneypot,
+ Honeypot,
MassScanner,
Sensor,
Statistics,
@@ -27,7 +27,7 @@
class TorExitNodeModelAdmin(admin.ModelAdmin):
list_display = ["ip_address", "added", "reason"]
search_fields = ["ip_address"]
- search_help_text = ["search for the IP address"]
+ search_help_text = "search for the IP address"
@admin.register(Sensor)
@@ -35,7 +35,7 @@ class SensorsModelAdmin(admin.ModelAdmin):
list_display = ["id", "address", "country", "label"]
list_editable = ["label"]
search_fields = ["address", "label"]
- search_help_text = ["search for the sensor IP address or label"]
+ search_help_text = "search for the sensor IP address or label"
@admin.register(Statistics)
@@ -43,14 +43,14 @@ class StatisticsModelAdmin(admin.ModelAdmin):
list_display = ["source", "view", "request_date"]
list_filter = ["source"]
search_fields = ["source"]
- search_help_text = ["search for the IP address source"]
+ search_help_text = "search for the IP address source"
@admin.register(WhatsMyIPDomain)
class WhatsMyIPModelAdmin(admin.ModelAdmin):
list_display = ["domain", "added"]
search_fields = ["domain"]
- search_help_text = ["search for the domain"]
+ search_help_text = "search for the domain"
@admin.register(MassScanner)
@@ -58,7 +58,7 @@ class MassScannersModelAdmin(admin.ModelAdmin):
list_display = ["ip_address", "added", "reason"]
list_filter = ["reason"]
search_fields = ["ip_address"]
- search_help_text = ["search for the IP address source"]
+ search_help_text = "search for the IP address"
@admin.register(FireHolList)
@@ -66,7 +66,7 @@ class FireHolListModelAdmin(admin.ModelAdmin):
list_display = ["ip_address", "added", "source"]
list_filter = ["source"]
search_fields = ["ip_address"]
- search_help_text = ["search for the IP address"]
+ search_help_text = "search for the IP address"
class SessionInline(admin.TabularInline):
@@ -101,7 +101,7 @@ class CowrieSessionModelAdmin(admin.ModelAdmin):
"source",
]
search_fields = ["source__name"]
- search_help_text = ["search for the IP address source"]
+ search_help_text = "search for the IP address source"
raw_id_fields = ["source", "commands"]
list_filter = ["login_attempt", "command_execution"]
@@ -113,7 +113,7 @@ def credential_list(self, session):
class CredentialModelAdmin(admin.ModelAdmin):
list_display = ["username", "password"]
search_fields = ["username", "password"]
- search_help_text = ["search for username or password"]
+ search_help_text = "search for username or password"
@admin.register(CommandSequence)
@@ -138,7 +138,7 @@ class IOCModelAdmin(admin.ModelAdmin):
"related_urls",
"scanner",
"payload_request",
- "general_honeypots",
+ "honeypots_list",
"sensor_list",
"ip_reputation",
"firehol_categories",
@@ -154,13 +154,13 @@ class IOCModelAdmin(admin.ModelAdmin):
"autonomous_system",
]
search_fields = ["name", "related_ioc__name"]
- search_help_text = ["search for the IP address source"]
+ search_help_text = "search by IOC name or related IOC name"
raw_id_fields = ["related_ioc"]
- filter_horizontal = ["general_honeypot", "sensors"]
+ filter_horizontal = ["honeypots", "sensors"]
inlines = [SessionInline]
- def general_honeypots(self, ioc):
- return ", ".join([str(element) for element in ioc.general_honeypot.all()])
+ def honeypots_list(self, ioc):
+ return ", ".join([str(element) for element in ioc.honeypots.all()])
def sensor_list(self, ioc):
return ", ".join([str(sensor.address) for sensor in ioc.sensors.all()])
@@ -180,11 +180,11 @@ def autonomous_system_display(self, ioc):
def get_queryset(self, request):
"""Override to optimize queries and avoid N+1 problems."""
- return super().get_queryset(request).select_related("autonomous_system").prefetch_related("sensors", "general_honeypot")
+ return super().get_queryset(request).select_related("autonomous_system").prefetch_related("sensors", "honeypots")
-@admin.register(GeneralHoneypot)
-class GeneralHoneypotAdmin(admin.ModelAdmin):
+@admin.register(Honeypot)
+class HoneypotAdmin(admin.ModelAdmin):
list_display = [
"name",
"active",
diff --git a/greedybear/cronjobs/extraction/ioc_processor.py b/greedybear/cronjobs/extraction/ioc_processor.py
index fd79c721..c2d80d9f 100644
--- a/greedybear/cronjobs/extraction/ioc_processor.py
+++ b/greedybear/cronjobs/extraction/ioc_processor.py
@@ -31,7 +31,7 @@ def add_ioc(
self,
ioc: IOC,
attack_type: str,
- general_honeypot_name: str = None,
+ honeypot_name: str = None,
) -> IOC | None:
"""
Process an IOC record.
@@ -42,7 +42,7 @@ def add_ioc(
Args:
ioc: IOC instance to process.
attack_type: Type of attack (SCANNER or PAYLOAD_REQUEST).
- general_honeypot_name: Optional honeypot name to associate with the IOC.
+ honeypot_name: Optional honeypot name to associate with the IOC.
Returns:
The persisted IOC record, or None if filtered out.
@@ -69,8 +69,8 @@ def add_ioc(
self.log.debug(f"{ioc} is already known - updating record")
ioc_record = self._merge_iocs(ioc_record, ioc)
- if general_honeypot_name is not None:
- ioc_record = self.ioc_repo.add_honeypot_to_ioc(general_honeypot_name, ioc_record)
+ if honeypot_name is not None:
+ ioc_record = self.ioc_repo.add_honeypot_to_ioc(honeypot_name, ioc_record)
ioc_record = self._update_days_seen(ioc_record)
ioc_record.scanner = ioc_record.scanner or (attack_type == SCANNER)
@@ -110,6 +110,8 @@ def _merge_iocs(self, existing: IOC, new: IOC) -> IOC:
# we will always update attacker_country if incoming value exists
if new.attacker_country:
existing.attacker_country = new.attacker_country
+ if new.attacker_country_code and len(new.attacker_country_code) == 2:
+ existing.attacker_country_code = new.attacker_country_code
# Add sensors from new IOC (existing is already saved, so ManyToMany works).
# We retrieve sensors from the temporary attribute of the input IOC object.
diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py
index 8f50e4f3..f434bd83 100644
--- a/greedybear/cronjobs/extraction/pipeline.py
+++ b/greedybear/cronjobs/extraction/pipeline.py
@@ -1,6 +1,8 @@
import logging
from collections import defaultdict
+from django.core.cache import caches
+
from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory
from greedybear.cronjobs.repositories import (
ElasticRepository,
@@ -104,4 +106,15 @@ def execute(self) -> int:
UpdateScores().score_only(ioc_records)
ioc_record_count += len(ioc_records)
+ # 5. Invalidate API caches only if any IOC records were processed
+ 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)
+
return ioc_record_count
diff --git a/greedybear/cronjobs/extraction/strategies/cowrie.py b/greedybear/cronjobs/extraction/strategies/cowrie.py
index aa7b043b..e168e04f 100644
--- a/greedybear/cronjobs/extraction/strategies/cowrie.py
+++ b/greedybear/cronjobs/extraction/strategies/cowrie.py
@@ -95,7 +95,7 @@ def _get_scanners(self, hits: list[dict]) -> None:
"""Extract scanner IPs and sessions."""
for ioc in iocs_from_hits(hits):
self.log.info(f"found IP {ioc.name} by honeypot cowrie")
- ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=SCANNER, general_honeypot_name="Cowrie")
+ ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=SCANNER, honeypot_name="Cowrie")
if ioc_record:
self.ioc_records.append(ioc_record)
threatfox_submission(ioc_record, ioc.related_urls, self.log)
@@ -142,7 +142,7 @@ def _extract_possible_payload_in_messages(self, hits: list[dict]) -> None:
sensor = hit.get("_sensor")
if sensor:
ioc._sensors_to_add = [sensor]
- self.ioc_processor.add_ioc(ioc, attack_type=PAYLOAD_REQUEST, general_honeypot_name="Cowrie")
+ self.ioc_processor.add_ioc(ioc, attack_type=PAYLOAD_REQUEST, honeypot_name="Cowrie")
self._add_fks(scanner_ip, payload_hostname)
self.payloads_in_message += 1
@@ -182,7 +182,7 @@ def _get_url_downloads(self, hits: list[dict]) -> None:
sensor = hit.get("_sensor")
if sensor:
ioc._sensors_to_add = [sensor]
- ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=PAYLOAD_REQUEST, general_honeypot_name="Cowrie")
+ ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=PAYLOAD_REQUEST, honeypot_name="Cowrie")
if ioc_record:
self.added_url_downloads += 1
threatfox_submission(ioc_record, ioc.related_urls, self.log)
diff --git a/greedybear/cronjobs/extraction/strategies/generic.py b/greedybear/cronjobs/extraction/strategies/generic.py
index f4653e1f..81048317 100644
--- a/greedybear/cronjobs/extraction/strategies/generic.py
+++ b/greedybear/cronjobs/extraction/strategies/generic.py
@@ -23,7 +23,7 @@ def extract_from_hits(self, hits: list[dict]) -> None:
"""
for ioc in iocs_from_hits(hits):
self.log.info(f"IoC {ioc.name} found by honeypot {self.honeypot}")
- ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=SCANNER, general_honeypot_name=self.honeypot)
+ ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=SCANNER, honeypot_name=self.honeypot)
if ioc_record:
self.ioc_records.append(ioc_record)
threatfox_submission(ioc_record, ioc.related_urls, self.log)
diff --git a/greedybear/cronjobs/extraction/strategies/heralding.py b/greedybear/cronjobs/extraction/strategies/heralding.py
index 07848a10..4508bf9c 100644
--- a/greedybear/cronjobs/extraction/strategies/heralding.py
+++ b/greedybear/cronjobs/extraction/strategies/heralding.py
@@ -73,7 +73,7 @@ def _get_scanners(self, hits: list[dict]) -> None:
ioc_record = self.ioc_processor.add_ioc(
ioc,
attack_type=SCANNER,
- general_honeypot_name=HERALDING_HONEYPOT,
+ honeypot_name=HERALDING_HONEYPOT,
)
if ioc_record:
self.ioc_records.append(ioc_record)
diff --git a/greedybear/cronjobs/extraction/strategies/tanner.py b/greedybear/cronjobs/extraction/strategies/tanner.py
index 73411548..fe952b74 100644
--- a/greedybear/cronjobs/extraction/strategies/tanner.py
+++ b/greedybear/cronjobs/extraction/strategies/tanner.py
@@ -114,7 +114,7 @@ def _get_scanners(self, hits: list[dict]) -> None:
"""Extract scanner IPs from hits."""
for ioc in iocs_from_hits(hits):
self.log.info(f"found IP {ioc.name} by honeypot {self.honeypot}")
- ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=SCANNER, general_honeypot_name=TANNER_HONEYPOT)
+ ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=SCANNER, honeypot_name=TANNER_HONEYPOT)
if ioc_record:
self.ioc_records.append(ioc_record)
threatfox_submission(ioc_record, ioc.related_urls, self.log)
@@ -272,7 +272,7 @@ def _extract_rfi_hostnames(self, hit: dict, scanner_ip: str, request_text: str)
if sensor:
ioc._sensors_to_add = [sensor]
- ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=PAYLOAD_REQUEST, general_honeypot_name=TANNER_HONEYPOT)
+ ioc_record = self.ioc_processor.add_ioc(ioc, attack_type=PAYLOAD_REQUEST, honeypot_name=TANNER_HONEYPOT)
if ioc_record:
self.rfi_hostnames_added += 1
threatfox_submission(ioc_record, ioc.related_urls, self.log)
diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py
index efaf16c9..94cb6158 100644
--- a/greedybear/cronjobs/extraction/utils.py
+++ b/greedybear/cronjobs/extraction/utils.py
@@ -174,8 +174,10 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]:
# Sort sensors by ID for consistent processing order
sensors = sorted(sensors_map.values(), key=lambda s: s.id)
- geoip = hits[0].get("geoip", {}) if hits else {}
+ geoip = next((h.get("geoip") for h in hits if h.get("geoip")), {})
attacker_country = geoip.get("country_name", "")
+ raw_country_code = geoip.get("country_iso_code", "")
+ attacker_country_code = raw_country_code if len(raw_country_code) == 2 else ""
asn = geoip.get("asn")
as_name = geoip.get("as_org", "")
@@ -185,12 +187,13 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]:
name=ip,
type=get_ioc_type(ip),
interaction_count=len(hits),
- ip_reputation=correct_ip_reputation(ip, hits[0].get("ip_rep", ""), mass_scanner_ips),
+ ip_reputation=correct_ip_reputation(ip, next((h.get("ip_rep", "") for h in hits if h.get("ip_rep")), ""), mass_scanner_ips),
autonomous_system=autonomous_system,
destination_ports=sorted(set(dest_ports)),
login_attempts=login_attempts,
firehol_categories=firehol_categories,
attacker_country=attacker_country,
+ attacker_country_code=attacker_country_code,
)
# Attach sensors to temporary attribute for later processing.
# We cannot use `ioc.sensors.add()` here because the IOC instance is not yet saved
@@ -291,7 +294,7 @@ def threatfox_submission(ioc_record: IOC, related_urls: list, log: Logger) -> No
if hasattr(ioc_record, "_seen_honeypots"):
seen_honeypots = ioc_record._seen_honeypots
else:
- seen_honeypots = [hp.name for hp in ioc_record.general_honeypot.all()]
+ seen_honeypots = [hp.name for hp in ioc_record.honeypots.all()]
seen_honeypots_str = ", ".join(seen_honeypots)
diff --git a/greedybear/cronjobs/repositories/ioc.py b/greedybear/cronjobs/repositories/ioc.py
index 4cc04701..00b13561 100644
--- a/greedybear/cronjobs/repositories/ioc.py
+++ b/greedybear/cronjobs/repositories/ioc.py
@@ -4,7 +4,7 @@
from django.db import IntegrityError
from django.db.models import F
-from greedybear.models import IOC, GeneralHoneypot
+from greedybear.models import IOC, Honeypot
class IocRepository:
@@ -18,7 +18,7 @@ class IocRepository:
def __init__(self):
"""Initialize the repository and populate the honeypot cache from the database."""
self.log = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
- self._honeypot_cache = {self._normalize_name(hp.name): hp for hp in GeneralHoneypot.objects.all()}
+ self._honeypot_cache = {self._normalize_name(hp.name): hp for hp in Honeypot.objects.all()}
def _normalize_name(self, name: str) -> str:
"""Normalize honeypot names for consistent cache and DB usage."""
@@ -40,13 +40,13 @@ def add_honeypot_to_ioc(self, honeypot_name: str, ioc: IOC) -> IOC:
if hasattr(ioc, "_seen_honeypots"):
honeypot_set = set(ioc._seen_honeypots)
else:
- honeypot_set = {self._normalize_name(hp.name) for hp in ioc.general_honeypot.all()}
+ honeypot_set = {self._normalize_name(hp.name) for hp in ioc.honeypots.all()}
if normalized_name not in honeypot_set:
self.log.debug(f"adding honeypot {honeypot_name} to IoC {ioc}")
honeypot = self._honeypot_cache.get(normalized_name)
if honeypot is not None:
- ioc.general_honeypot.add(honeypot)
+ ioc.honeypots.add(honeypot)
honeypot_set.add(normalized_name)
else:
self.log.error(f"Honeypot '{honeypot_name}' not found in cache; skipping association for IOC {ioc}")
@@ -56,7 +56,7 @@ def add_honeypot_to_ioc(self, honeypot_name: str, ioc: IOC) -> IOC:
ioc._seen_honeypots = list(honeypot_set)
return ioc
- def create_honeypot(self, honeypot_name: str) -> GeneralHoneypot:
+ def create_honeypot(self, honeypot_name: str) -> Honeypot:
"""
Create a new honeypot or return an existing one.
@@ -68,12 +68,12 @@ def create_honeypot(self, honeypot_name: str) -> GeneralHoneypot:
honeypot_name: Name for the new honeypot.
Returns:
- A GeneralHoneypot instance (newly created or existing).
+ A Honeypot instance (newly created or existing).
"""
normalized = self._normalize_name(honeypot_name)
try:
- honeypot = GeneralHoneypot.objects.create(
+ honeypot = Honeypot.objects.create(
name=honeypot_name,
active=True,
)
@@ -86,14 +86,14 @@ def create_honeypot(self, honeypot_name: str) -> GeneralHoneypot:
self._honeypot_cache[normalized] = honeypot
return honeypot
- def get_active_honeypots(self) -> list[GeneralHoneypot]:
+ def get_active_honeypots(self) -> list[Honeypot]:
"""
Retrieve a list of all active honeypots.
Returns:
A list of all active honeypots in the database.
"""
- return list(GeneralHoneypot.objects.filter(active=True))
+ return list(Honeypot.objects.filter(active=True))
def get_ioc_by_name(self, name: str) -> IOC | None:
"""
@@ -106,11 +106,11 @@ def get_ioc_by_name(self, name: str) -> IOC | None:
The matching IOC, or None if not found.
"""
try:
- return IOC.objects.prefetch_related("general_honeypot").get(name=name)
+ return IOC.objects.prefetch_related("honeypots").get(name=name)
except IOC.DoesNotExist:
return None
- def get_hp_by_name(self, name: str) -> GeneralHoneypot | None:
+ def get_hp_by_name(self, name: str) -> Honeypot | None:
"""
Retrieve a honeypot by its name.
@@ -118,9 +118,9 @@ def get_hp_by_name(self, name: str) -> GeneralHoneypot | None:
name: The honeypot name to look up.
Returns:
- The matching GeneralHoneypot, or None if not found.
+ The matching Honeypot, or None if not found.
"""
- return GeneralHoneypot.objects.filter(name__iexact=name).first()
+ return Honeypot.objects.filter(name__iexact=name).first()
def is_empty(self) -> bool:
"""
@@ -188,7 +188,7 @@ def get_scanners_for_scoring(self, score_fields: list[str]) -> list[IOC]:
Returns:
QuerySet of IOC objects with only name and score fields loaded.
"""
- return IOC.objects.filter(general_honeypot__active=True).filter(scanner=True).distinct().only("name", *score_fields)
+ return IOC.objects.filter(honeypots__active=True).filter(scanner=True).distinct().only("name", *score_fields)
def get_scanners_by_pks(self, primary_keys: set[int]):
"""
@@ -198,14 +198,14 @@ def get_scanners_by_pks(self, primary_keys: set[int]):
primary_keys: Set of IOC primary keys to retrieve.
Returns:
- QuerySet of IOC objects with prefetched general_honeypot relationships
+ QuerySet of IOC objects with prefetched honeypots relationships
and annotated with value and honeypots fields.
"""
return (
IOC.objects.filter(pk__in=primary_keys)
- .prefetch_related("general_honeypot")
+ .prefetch_related("honeypots")
.annotate(value=F("name"))
- .annotate(honeypots=ArrayAgg("general_honeypot__name", distinct=True))
+ .annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True))
.values()
)
@@ -224,11 +224,11 @@ def get_recent_scanners(self, cutoff_date, days_lookback: int = 30):
QuerySet of IOC objects with prefetched relationships and annotations.
"""
return (
- IOC.objects.filter(general_honeypot__active=True)
+ IOC.objects.filter(honeypots__active=True)
.filter(last_seen__gte=cutoff_date, scanner=True)
- .prefetch_related("general_honeypot")
+ .prefetch_related("honeypots")
.annotate(value=F("name"))
- .annotate(honeypots=ArrayAgg("general_honeypot__name", distinct=True))
+ .annotate(honeypot_names=ArrayAgg("honeypots__name", distinct=True))
.values()
)
diff --git a/greedybear/cronjobs/schedules.py b/greedybear/cronjobs/schedules.py
index b773d0fb..9ad3d41f 100644
--- a/greedybear/cronjobs/schedules.py
+++ b/greedybear/cronjobs/schedules.py
@@ -1,7 +1,20 @@
+import hashlib
+
from django.conf import settings
from django_q.models import Schedule
+def _external_weekly_cron(job_name: str) -> str:
+ """Return a deterministic Sunday cron for external jobs outside 00:00-02:00."""
+ seed_value = f"{settings.SECRET_KEY}:{job_name}"
+ digest = hashlib.sha256(seed_value.encode()).digest()
+ seed_int = int.from_bytes(digest[:8], byteorder="big", signed=False)
+
+ minute = seed_int % 60
+ hour = 2 + ((seed_int // 60) % 22)
+ return f"{minute} {hour} * * 0"
+
+
def setup_schedules():
"""
Configure Django Q2 scheduled tasks for the GreedyBear application.
@@ -47,29 +60,29 @@ def setup_schedules():
"func": "greedybear.tasks.clean_up_db",
"cron": "7 1 * * *",
},
- # Mass Scanners: Weekly (Sunday) at 01:07
+ # Mass Scanners: Weekly (Sunday) at deterministic time outside 00:00-02:00
{
"name": "get_mass_scanners",
"func": "greedybear.tasks.get_mass_scanners",
- "cron": "7 1 * * 0",
+ "cron": _external_weekly_cron("get_mass_scanners"),
},
- # WhatsMyIP: Weekly (Sunday) at 01:07
+ # WhatsMyIP: Weekly (Sunday) at deterministic time outside 00:00-02:00
{
"name": "get_whatsmyip",
"func": "greedybear.tasks.get_whatsmyip",
- "cron": "7 1 * * 0",
+ "cron": _external_weekly_cron("get_whatsmyip"),
},
- # Firehol Lists: Weekly (Sunday) at 01:07
+ # Firehol Lists: Weekly (Sunday) at deterministic time outside 00:00-02:00
{
"name": "extract_firehol_lists",
"func": "greedybear.tasks.extract_firehol_lists",
- "cron": "7 1 * * 0",
+ "cron": _external_weekly_cron("extract_firehol_lists"),
},
- # Tor Exit Nodes: Weekly (Sunday) at 01:07
+ # Tor Exit Nodes: Weekly (Sunday) at deterministic time outside 00:00-02:00
{
"name": "get_tor_exit_nodes",
"func": "greedybear.tasks.get_tor_exit_nodes",
- "cron": "7 1 * * 0",
+ "cron": _external_weekly_cron("get_tor_exit_nodes"),
},
# 10. Reverse DNS Scanner Check: Daily at 06:07
{
@@ -77,17 +90,17 @@ def setup_schedules():
"func": "greedybear.tasks.check_reverse_dns",
"cron": "7 6 * * *",
},
- # 11. ThreatFox Enrichment: Weekly (Sunday) at 01:07
+ # 11. ThreatFox Enrichment: Weekly (Sunday) at deterministic time outside 00:00-02:00
{
"name": "enrich_threatfox",
"func": "greedybear.tasks.enrich_threatfox",
- "cron": "7 1 * * 0",
+ "cron": _external_weekly_cron("enrich_threatfox"),
},
- # 12. AbuseIPDB Enrichment: Weekly (Sunday) at 01:07
+ # 12. AbuseIPDB Enrichment: Weekly (Sunday) at deterministic time outside 00:00-02:00
{
"name": "enrich_abuseipdb",
"func": "greedybear.tasks.enrich_abuseipdb",
- "cron": "7 1 * * 0",
+ "cron": _external_weekly_cron("enrich_abuseipdb"),
},
]
diff --git a/greedybear/migrations/0048_add_attacker_country_code.py b/greedybear/migrations/0048_add_attacker_country_code.py
new file mode 100644
index 00000000..e9b5f899
--- /dev/null
+++ b/greedybear/migrations/0048_add_attacker_country_code.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.12 on 2026-04-01 19:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("greedybear", "0047_credential_sources"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ioc",
+ name="attacker_country_code",
+ field=models.CharField(blank=True, default="", max_length=2),
+ ),
+ ]
diff --git a/greedybear/migrations/0049_rename_generalhoneypot_honeypot_and_more.py b/greedybear/migrations/0049_rename_generalhoneypot_honeypot_and_more.py
new file mode 100644
index 00000000..9b46c280
--- /dev/null
+++ b/greedybear/migrations/0049_rename_generalhoneypot_honeypot_and_more.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.12 on 2026-03-17 00:08
+
+import django.db.models.functions.text
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("greedybear", "0048_add_attacker_country_code"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="GeneralHoneypot",
+ new_name="Honeypot",
+ ),
+ migrations.RemoveConstraint(
+ model_name="honeypot",
+ name="unique_generalhoneypot_name_ci",
+ ),
+ migrations.RenameField(
+ model_name="ioc",
+ old_name="general_honeypot",
+ new_name="honeypots",
+ ),
+ migrations.AddConstraint(
+ model_name="honeypot",
+ constraint=models.UniqueConstraint(django.db.models.functions.text.Lower("name"), name="unique_honeypot_name_ci"),
+ ),
+ ]
diff --git a/greedybear/models.py b/greedybear/models.py
index 9644e700..4156634c 100644
--- a/greedybear/models.py
+++ b/greedybear/models.py
@@ -37,12 +37,12 @@ def __str__(self):
return f"{self.address} ({self.label})" if self.label else self.address
-class GeneralHoneypot(models.Model):
+class Honeypot(models.Model):
name = models.CharField(max_length=15)
active = models.BooleanField(default=True)
class Meta:
- constraints = [models.UniqueConstraint(Lower("name"), name="unique_generalhoneypot_name_ci")]
+ constraints = [models.UniqueConstraint(Lower("name"), name="unique_honeypot_name_ci")]
def __str__(self):
return self.name
@@ -84,6 +84,11 @@ class IOC(models.Model):
blank=True,
default="",
)
+ attacker_country_code = models.CharField(
+ max_length=2,
+ blank=True,
+ default="",
+ )
autonomous_system = models.ForeignKey(
AutonomousSystem,
on_delete=models.SET_NULL,
@@ -92,7 +97,7 @@ class IOC(models.Model):
related_name="iocs",
)
# FEEDS - list of honeypots from general list, from which the IOC was detected
- general_honeypot = models.ManyToManyField(GeneralHoneypot, blank=True)
+ honeypots = models.ManyToManyField(Honeypot, blank=True)
# SENSORS - list of T-Pot sensors that detected this IOC
sensors = models.ManyToManyField(Sensor, blank=True)
scanner = models.BooleanField(default=False)
diff --git a/pyproject.toml b/pyproject.toml
index a6d333bd..bfcfe0d9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "greedybear"
-version = "3.3.0"
+version = "3.3.1"
requires-python = "==3.13.*"
dependencies = [
# Django core
@@ -12,21 +12,21 @@ dependencies = [
"croniter==6.2.2",
"certego-saas==0.7.12",
# Server Gateway Interface
- "gunicorn==25.2.0",
+ "gunicorn==25.3.0",
# Data stores
"elasticsearch==9.3.0",
- "psycopg2-binary==2.9.11",
+ "psycopg[c]==3.3.3",
# ML / data science
"scikit-learn==1.8.0",
- "pandas==3.0.1",
- "numpy==2.4.3",
+ "pandas==3.0.2",
+ "numpy==2.4.4",
"joblib==1.5.3",
"datasketch==1.9.0",
# File Format Support
"feedparser==6.0.12",
"stix2==3.0.2",
# Utilities
- "requests==2.33.0",
+ "requests==2.33.1",
"slack-sdk==3.41.0",
]
@@ -37,7 +37,7 @@ dev = [
"django-watchfiles==1.4.0",
]
lint = [
- "ruff==0.15.8",
+ "ruff==0.15.9",
]
[tool.uv]
diff --git a/tests/__init__.py b/tests/__init__.py
index 886a055e..02134026 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -13,7 +13,7 @@
CommandSequence,
CowrieSession,
Credential,
- GeneralHoneypot,
+ Honeypot,
IocType,
)
@@ -24,15 +24,15 @@ def setUpTestData(cls):
super().setUpTestData()
cls.as_obj, _ = AutonomousSystem.objects.get_or_create(asn="12345", defaults={"name": "greedybear"})
- cls.heralding = GeneralHoneypot.objects.get_or_create(name="Heralding", defaults={"active": True})[0]
- cls.ciscoasa = GeneralHoneypot.objects.get_or_create(name="Ciscoasa", defaults={"active": True})[0]
- cls.ddospot = GeneralHoneypot.objects.get_or_create(name="Ddospot", defaults={"active": False})[0]
+ cls.heralding = Honeypot.objects.get_or_create(name="Heralding", defaults={"active": True})[0]
+ cls.ciscoasa = Honeypot.objects.get_or_create(name="Ciscoasa", defaults={"active": True})[0]
+ cls.ddospot = Honeypot.objects.get_or_create(name="Ddospot", defaults={"active": False})[0]
cls.current_time = datetime.now()
# Create honeypots for Cowrie and Log4pot (replacing boolean fields)
- cls.cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
- cls.log4pot_hp = GeneralHoneypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
+ cls.cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cls.log4pot_hp = Honeypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
cls.ioc = IOC.objects.create(
name="140.246.171.141",
@@ -117,20 +117,20 @@ def setUpTestData(cls):
expected_interactions=5.5,
)
- cls.ioc.general_honeypot.add(cls.heralding) # FEEDS
- cls.ioc.general_honeypot.add(cls.ciscoasa) # FEEDS
- cls.ioc.general_honeypot.add(cls.cowrie_hp) # Cowrie honeypot
- cls.ioc.general_honeypot.add(cls.log4pot_hp) # Log4pot honeypot
+ cls.ioc.honeypots.add(cls.heralding) # FEEDS
+ cls.ioc.honeypots.add(cls.ciscoasa) # FEEDS
+ cls.ioc.honeypots.add(cls.cowrie_hp) # Cowrie honeypot
+ cls.ioc.honeypots.add(cls.log4pot_hp) # Log4pot honeypot
cls.ioc.save()
- cls.ioc_2.general_honeypot.add(cls.heralding) # FEEDS
- cls.ioc_2.general_honeypot.add(cls.ciscoasa) # FEEDS
- cls.ioc_2.general_honeypot.add(cls.cowrie_hp) # Cowrie honeypot
- cls.ioc_2.general_honeypot.add(cls.log4pot_hp) # Log4pot honeypot
+ cls.ioc_2.honeypots.add(cls.heralding) # FEEDS
+ cls.ioc_2.honeypots.add(cls.ciscoasa) # FEEDS
+ cls.ioc_2.honeypots.add(cls.cowrie_hp) # Cowrie honeypot
+ cls.ioc_2.honeypots.add(cls.log4pot_hp) # Log4pot honeypot
cls.ioc_2.save()
- cls.ioc_3.general_honeypot.add(cls.cowrie_hp) # Cowrie honeypot
+ cls.ioc_3.honeypots.add(cls.cowrie_hp) # Cowrie honeypot
cls.ioc_3.save()
- cls.ioc_domain.general_honeypot.add(cls.heralding) # FEEDS
- cls.ioc_domain.general_honeypot.add(cls.log4pot_hp) # Log4pot honeypot
+ cls.ioc_domain.honeypots.add(cls.heralding) # FEEDS
+ cls.ioc_domain.honeypots.add(cls.log4pot_hp) # Log4pot honeypot
cls.ioc_domain.save()
# IOC with an inactive-only honeypot
@@ -145,7 +145,7 @@ def setUpTestData(cls):
interaction_count=1,
attacker_country="Russia",
)
- cls.ioc_inactive_country.general_honeypot.add(cls.ddospot)
+ cls.ioc_inactive_country.honeypots.add(cls.ddospot)
cls.ioc_inactive_country.save()
cls.cmd_seq = ["cd foo", "ls -la"]
@@ -229,6 +229,8 @@ def _create_mock_ioc(
ip_reputation="",
asn=1234,
firehol_categories=None,
+ attacker_country="",
+ attacker_country_code="",
):
mock = Mock(spec=IOC)
mock.name = name
@@ -246,6 +248,8 @@ def _create_mock_ioc(
mock.ip_reputation = ip_reputation
mock.firehol_categories = firehol_categories if firehol_categories is not None else []
mock.number_of_days_seen = len(mock.days_seen)
+ mock.attacker_country = attacker_country
+ mock.attacker_country_code = attacker_country_code
if asn is not None:
mock.autonomous_system = Mock()
diff --git a/tests/api/test_feed_types.py b/tests/api/test_feed_types.py
index 35118fc9..4402bddd 100644
--- a/tests/api/test_feed_types.py
+++ b/tests/api/test_feed_types.py
@@ -5,23 +5,23 @@
from django.test import override_settings
from rest_framework.test import APIClient
-from greedybear.models import IOC, GeneralHoneypot, IocType
+from greedybear.models import IOC, Honeypot, IocType
from tests import CustomTestCase
class FeedTypeAPITestCase(CustomTestCase):
- """Test API feed handling with GeneralHoneypot M2M instead of boolean fields."""
+ """Test API feed handling with Honeypot M2M instead of boolean fields."""
def setUp(self):
self.client = APIClient()
self.client.force_authenticate(user=self.superuser)
# Ensure Cowrie and Log4pot honeypots exist
- self.cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
- self.log4pot_hp = GeneralHoneypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
+ self.cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ self.log4pot_hp = Honeypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
def test_feed_type_derived_from_m2m(self):
- """Verify feed_type is derived from general_honeypot M2M."""
+ """Verify feed_type is derived from honeypot M2M."""
response = self.client.get("/api/feeds/all/all/recent.json")
self.assertEqual(response.status_code, 200)
@@ -100,7 +100,7 @@ def test_feed_type_no_normalization_log4pot(self):
type=IocType.IP.value,
scanner=True,
)
- ioc.general_honeypot.add(self.log4pot_hp)
+ ioc.honeypots.add(self.log4pot_hp)
response = self.client.get("/api/feeds/all/all/recent.json")
self.assertEqual(response.status_code, 200)
diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py
index 7d0b50f8..027bd59e 100644
--- a/tests/api/views/test_feeds_advanced_view.py
+++ b/tests/api/views/test_feeds_advanced_view.py
@@ -133,7 +133,7 @@ def setUp(self):
interaction_count=1,
login_attempts=0,
)
- self.ioc2.general_honeypot.add(self.cowrie_hp)
+ self.ioc2.honeypots.add(self.cowrie_hp)
self.ioc2.save()
# ── Advanced filtering ────────────────────────────────────────────────────
diff --git a/tests/api/views/test_feeds_asn_view.py b/tests/api/views/test_feeds_asn_view.py
index e6eace5c..fbe62a29 100644
--- a/tests/api/views/test_feeds_asn_view.py
+++ b/tests/api/views/test_feeds_asn_view.py
@@ -1,7 +1,8 @@
+from django.core.cache import cache, caches
from django.utils import timezone
from rest_framework.test import APIClient
-from greedybear.models import IOC, AutonomousSystem, GeneralHoneypot
+from greedybear.models import IOC, AutonomousSystem, Honeypot
from tests import CustomTestCase
@@ -12,8 +13,8 @@ class FeedsASNViewTestCase(CustomTestCase):
def setUpClass(cls):
super().setUpClass()
IOC.objects.all().delete()
- cls.testpot1, _ = GeneralHoneypot.objects.get_or_create(name="testpot1", active=True)
- cls.testpot2, _ = GeneralHoneypot.objects.get_or_create(name="testpot2", active=True)
+ cls.testpot1, _ = Honeypot.objects.get_or_create(name="testpot1", active=True)
+ cls.testpot2, _ = Honeypot.objects.get_or_create(name="testpot2", active=True)
cls.high_asn = "13335"
cls.low_asn = "16276"
@@ -33,7 +34,7 @@ def setUpClass(cls):
recurrence_probability=0.8,
expected_interactions=20.0,
)
- cls.ioc_high1.general_honeypot.add(cls.testpot1, cls.testpot2)
+ cls.ioc_high1.honeypots.add(cls.testpot1, cls.testpot2)
cls.ioc_high2 = IOC.objects.create(
name="high2.example.com",
@@ -46,7 +47,7 @@ def setUpClass(cls):
recurrence_probability=0.3,
expected_interactions=8.0,
)
- cls.ioc_high2.general_honeypot.add(cls.testpot1, cls.testpot2)
+ cls.ioc_high2.honeypots.add(cls.testpot1, cls.testpot2)
cls.ioc_low = IOC.objects.create(
name="low.example.com",
@@ -59,10 +60,12 @@ def setUpClass(cls):
recurrence_probability=0.1,
expected_interactions=3.0,
)
- cls.ioc_low.general_honeypot.add(cls.testpot1, cls.testpot2)
+ cls.ioc_low.honeypots.add(cls.testpot1, cls.testpot2)
def setUp(self):
super().setUp()
+ cache.clear()
+ caches["django-q"].clear()
self.client = APIClient()
self.client.force_authenticate(user=self.superuser)
self.url = "/api/feeds/asn/"
@@ -97,7 +100,7 @@ def test_200_asn_feed_aggregated_fields(self):
self.assertEqual(high_item["last_seen"], max(i.last_seen for i in high_iocs).isoformat())
# validating honeypots dynamically
- expected_honeypots = sorted({hp.name for i in high_iocs for hp in i.general_honeypot.all()})
+ expected_honeypots = sorted({hp.name for i in high_iocs for hp in i.honeypots.all()})
self.assertEqual(sorted(high_item["honeypots"]), expected_honeypots)
def test_200_asn_feed_default_ordering(self):
@@ -221,3 +224,45 @@ def test_asn_feed_with_empty_as_name(self):
# Restore original name
self.as_low.name = original_name
self.as_low.save(update_fields=["name"])
+
+ 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.
+ """
+ # 1. First request computes and caches the result
+ response1 = self.client.get(self.url)
+ self.assertEqual(response1.status_code, 200)
+ results1 = self._get_results(response1)
+ high_item1 = next((item for item in results1 if str(item["asn"]) == self.high_asn), None)
+ self.assertIsNotNone(high_item1)
+
+ # 2. Modify DB (e.g. change an IOC's attack count)
+ original_attack_count = self.ioc_high1.attack_count
+ self.ioc_high1.attack_count += 100
+ self.ioc_high1.save(update_fields=["attack_count"])
+
+ # 3. Second request should hit the cache and return SAME old value
+ response2 = self.client.get(self.url)
+ results2 = self._get_results(response2)
+ high_item2 = next((item for item in results2 if str(item["asn"]) == self.high_asn), None)
+
+ 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)
+
+ # 5. Third request should re-compute and show UPDATED DB value
+ response3 = self.client.get(self.url)
+ results3 = self._get_results(response3)
+ high_item3 = next((item for item in results3 if str(item["asn"]) == self.high_asn), None)
+
+ self.assertEqual(high_item3["total_attack_count"], high_item1["total_attack_count"] + 100)
+
+ # Cleanup DB
+ self.ioc_high1.attack_count = original_attack_count
+ self.ioc_high1.save(update_fields=["attack_count"])
diff --git a/tests/api/views/test_feeds_view.py b/tests/api/views/test_feeds_view.py
index 7188f4f2..16346fd7 100644
--- a/tests/api/views/test_feeds_view.py
+++ b/tests/api/views/test_feeds_view.py
@@ -17,7 +17,7 @@ def test_200_log4pot_feeds(self):
target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None)
self.assertIsNotNone(target_ioc)
- # feed_type now derived from general_honeypot M2M
+ # feed_type now derived from honeypot M2M
self.assertIn("log4pot", target_ioc["feed_type"])
self.assertIn("cowrie", target_ioc["feed_type"])
self.assertIn("heralding", target_ioc["feed_type"])
diff --git a/tests/api/views/test_general_honeypot_view.py b/tests/api/views/test_general_honeypot_view.py
index bdc5a5e4..a592812a 100644
--- a/tests/api/views/test_general_honeypot_view.py
+++ b/tests/api/views/test_general_honeypot_view.py
@@ -1,13 +1,13 @@
-from greedybear.models import GeneralHoneypot
+from greedybear.models import Honeypot
from tests import CustomTestCase
-class GeneralHoneypotViewTestCase(CustomTestCase):
+class HoneypotViewTestCase(CustomTestCase):
def test_200_all_general_honeypots(self):
- initial_count = GeneralHoneypot.objects.count()
+ initial_count = Honeypot.objects.count()
# add a general honeypot not active
- GeneralHoneypot(name="Adbhoney", active=False).save()
- self.assertEqual(GeneralHoneypot.objects.count(), initial_count + 1)
+ Honeypot(name="Adbhoney", active=False).save()
+ self.assertEqual(Honeypot.objects.count(), initial_count + 1)
response = self.client.get("/api/general_honeypot")
self.assertEqual(response.status_code, 200)
diff --git a/tests/api/views/test_health.py b/tests/api/views/test_health.py
index e1bac040..27481c37 100644
--- a/tests/api/views/test_health.py
+++ b/tests/api/views/test_health.py
@@ -5,7 +5,7 @@
from django.test import override_settings
from rest_framework.test import APIClient
-from greedybear.models import IOC, GeneralHoneypot
+from greedybear.models import IOC, Honeypot
from tests import CustomTestCase
User = get_user_model()
@@ -17,7 +17,7 @@ class HealthViewTestCase(CustomTestCase):
@classmethod
def setUpTestData(cls):
# deleting all existing objects to have predictable test counts
- GeneralHoneypot.objects.all().delete()
+ Honeypot.objects.all().delete()
IOC.objects.all().delete()
cls.superuser = User.objects.create_superuser(
@@ -26,8 +26,8 @@ def setUpTestData(cls):
password="adminpass",
)
- cls.testpot1 = GeneralHoneypot.objects.create(name="testpot1", active=True)
- cls.testpot2 = GeneralHoneypot.objects.create(name="testpot2", active=True)
+ cls.testpot1 = Honeypot.objects.create(name="testpot1", active=True)
+ cls.testpot2 = Honeypot.objects.create(name="testpot2", active=True)
cls.ioc1 = IOC.objects.create(
name="ioc1.example.com",
@@ -37,7 +37,7 @@ def setUpTestData(cls):
login_attempts=2,
first_seen=datetime.now() - timedelta(days=2),
)
- cls.ioc1.general_honeypot.add(cls.testpot1, cls.testpot2)
+ cls.ioc1.honeypots.add(cls.testpot1, cls.testpot2)
cls.ioc2 = IOC.objects.create(
name="ioc2.example.com",
@@ -47,7 +47,7 @@ def setUpTestData(cls):
login_attempts=1,
first_seen=datetime.now() - timedelta(hours=5),
)
- cls.ioc2.general_honeypot.add(cls.testpot1)
+ cls.ioc2.honeypots.add(cls.testpot1)
def setUp(self):
self.client = APIClient()
diff --git a/tests/api/views/test_statistics_view.py b/tests/api/views/test_statistics_view.py
index c7171039..59a7b4df 100644
--- a/tests/api/views/test_statistics_view.py
+++ b/tests/api/views/test_statistics_view.py
@@ -1,4 +1,4 @@
-from greedybear.models import GeneralHoneypot, Statistics, ViewType
+from greedybear.models import Honeypot, Statistics, ViewType
from tests import CustomTestCase
@@ -37,10 +37,10 @@ def test_200_enrichment_requests(self):
def test_200_feed_types(self):
# Count honeypots before adding new one
- initial_count = GeneralHoneypot.objects.count()
+ initial_count = Honeypot.objects.count()
# add a general honeypot without associated ioc
- GeneralHoneypot(name="Tanner", active=True).save()
- self.assertEqual(GeneralHoneypot.objects.count(), initial_count + 1)
+ Honeypot(name="Tanner", active=True).save()
+ self.assertEqual(Honeypot.objects.count(), initial_count + 1)
response = self.client.get("/api/statistics/feeds_types")
self.assertEqual(response.status_code, 200)
diff --git a/tests/greedybear/management/test_setup_schedules.py b/tests/greedybear/management/test_setup_schedules.py
index 7430d049..78b5384d 100644
--- a/tests/greedybear/management/test_setup_schedules.py
+++ b/tests/greedybear/management/test_setup_schedules.py
@@ -55,6 +55,49 @@ def test_extraction_interval_5(self, mock_schedule):
extract_call = next(c for c in calls if c[1]["name"] == "extract_all")
self.assertEqual(extract_call[1]["defaults"]["cron"], "*/5 * * * *")
+ @patch("greedybear.cronjobs.schedules.Schedule")
+ @override_settings(EXTRACTION_INTERVAL=10, SECRET_KEY="test-secret")
+ def test_external_weekly_jobs_cron_are_deterministic_and_outside_local_window(self, mock_schedule):
+ """External weekly jobs run on Sunday at deterministic times outside 00:00-02:00."""
+ mock_schedule.CRON = Schedule.CRON
+ mock_schedule.objects.update_or_create = MagicMock()
+ mock_schedule.objects.exclude = MagicMock(return_value=MagicMock())
+
+ call_command("setup_schedules")
+ first_calls = mock_schedule.objects.update_or_create.call_args_list
+
+ mock_schedule.objects.update_or_create.reset_mock()
+ call_command("setup_schedules")
+ second_calls = mock_schedule.objects.update_or_create.call_args_list
+
+ def get_cron(calls, name):
+ return next(c for c in calls if c[1]["name"] == name)[1]["defaults"]["cron"]
+
+ for job_name in (
+ "get_mass_scanners",
+ "get_whatsmyip",
+ "extract_firehol_lists",
+ "get_tor_exit_nodes",
+ "enrich_threatfox",
+ "enrich_abuseipdb",
+ ):
+ first_cron = get_cron(first_calls, job_name)
+ second_cron = get_cron(second_calls, job_name)
+
+ self.assertEqual(first_cron, second_cron)
+
+ minute_str, hour_str, day_of_month, month, day_of_week = first_cron.split()
+ hour = int(hour_str)
+ minute = int(minute_str)
+
+ self.assertGreaterEqual(hour, 2)
+ self.assertLessEqual(hour, 23)
+ self.assertGreaterEqual(minute, 0)
+ self.assertLessEqual(minute, 59)
+ self.assertEqual(day_of_month, "*")
+ self.assertEqual(month, "*")
+ self.assertEqual(day_of_week, "0")
+
def test_orphan_schedules_are_deleted(self):
"""Test that orphaned schedules not in active_schedules list are deleted."""
# Create an orphan schedule that's not in the active_schedules list
diff --git a/tests/test_cowrie_extraction.py b/tests/test_cowrie_extraction.py
index c1d66d5b..3f1c7ed6 100644
--- a/tests/test_cowrie_extraction.py
+++ b/tests/test_cowrie_extraction.py
@@ -132,8 +132,8 @@ def test_extract_payload_in_messages_with_url(self):
self.assertEqual(ioc_arg.name, "evil.com")
self.assertIn("http://evil.com/malware.exe", ioc_arg.related_urls)
- # Verify honeypot is set via general_honeypot_name argument
- self.assertEqual(call_args.kwargs.get("general_honeypot_name"), "Cowrie")
+ # Verify honeypot is set via honeypot_name argument
+ self.assertEqual(call_args.kwargs.get("honeypot_name"), "Cowrie")
def test_extract_payload_in_messages_no_url(self):
"""Test extraction when message has no URL."""
@@ -195,7 +195,7 @@ def test_get_url_downloads(self):
self.mock_ioc_repo.get_ioc_by_name.side_effect = [scanner_mock, payload_mock]
mock_payload_record = Mock()
- mock_payload_record.general_honeypot.all.return_value = []
+ mock_payload_record.honeypots.all.return_value = []
self.strategy.ioc_processor.add_ioc.return_value = mock_payload_record
self.strategy._get_url_downloads(hits)
@@ -434,4 +434,4 @@ def test_extract_from_hits_integration(self, mock_iocs_from_hits):
# Verify scanner was processed with Cowrie as honeypot
self.strategy.ioc_processor.add_ioc.assert_called()
call_args = self.strategy.ioc_processor.add_ioc.call_args
- self.assertEqual(call_args.kwargs.get("general_honeypot_name"), "Cowrie")
+ self.assertEqual(call_args.kwargs.get("honeypot_name"), "Cowrie")
diff --git a/tests/test_extraction_strategies.py b/tests/test_extraction_strategies.py
index 6aaec4f8..c729331e 100644
--- a/tests/test_extraction_strategies.py
+++ b/tests/test_extraction_strategies.py
@@ -30,7 +30,7 @@ def test_processes_enabled_honeypot(self, mock_threatfox, mock_iocs_from_hits):
self.strategy.extract_from_hits(hits)
mock_iocs_from_hits.assert_called_once_with(hits)
- self.strategy.ioc_processor.add_ioc.assert_called_once_with(mock_ioc, attack_type=SCANNER, general_honeypot_name="TestHoneypot")
+ self.strategy.ioc_processor.add_ioc.assert_called_once_with(mock_ioc, attack_type=SCANNER, honeypot_name="TestHoneypot")
self.assertEqual(len(self.strategy.ioc_records), 1)
mock_threatfox.assert_called_once()
@@ -84,7 +84,7 @@ def test_logs_correct_honeypot_name(self, mock_iocs_from_hits):
self.strategy.extract_from_hits(hits)
call_kwargs = self.strategy.ioc_processor.add_ioc.call_args[1]
- self.assertEqual(call_kwargs["general_honeypot_name"], "TestHoneypot")
+ self.assertEqual(call_kwargs["honeypot_name"], "TestHoneypot")
@patch("greedybear.cronjobs.extraction.strategies.generic.iocs_from_hits")
@patch("greedybear.cronjobs.extraction.strategies.generic.threatfox_submission")
@@ -108,4 +108,4 @@ def test_processes_ioc_with_sensors(self, mock_threatfox, mock_iocs_from_hits):
self.strategy.extract_from_hits(hits)
# Should call add_ioc once with IOC object (sensors are attached to it)
- self.strategy.ioc_processor.add_ioc.assert_called_once_with(mock_ioc, attack_type=SCANNER, general_honeypot_name="TestHoneypot")
+ self.strategy.ioc_processor.add_ioc.assert_called_once_with(mock_ioc, attack_type=SCANNER, honeypot_name="TestHoneypot")
diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py
index 8ca146e1..fa703e42 100644
--- a/tests/test_extraction_utils.py
+++ b/tests/test_extraction_utils.py
@@ -413,6 +413,32 @@ def test_handles_missing_geoip(self):
ioc = iocs[0]
self.assertIsNone(ioc.autonomous_system)
+ def test_geoip_from_later_hit_when_first_has_none(self):
+ """ASN and country must be taken from any geoip-enriched hit, not only hits[0]."""
+ hits = [
+ self._create_hit(src_ip="1.2.3.4"),
+ self._create_hit(src_ip="1.2.3.4", asn=13335),
+ ]
+ hits[1]["geoip"]["as_org"] = "CLOUDFLARE"
+ hits[1]["geoip"]["country_name"] = "United States"
+
+ iocs = iocs_from_hits(hits)
+ ioc = iocs[0]
+
+ self.assertIsNotNone(ioc.autonomous_system)
+ self.assertEqual(ioc.autonomous_system.asn, 13335)
+ self.assertEqual(ioc.attacker_country, "United States")
+
+ def test_ip_rep_from_later_hit_when_first_has_none(self):
+ """ip_rep should be read from first hit that carries it, not blindly hits[0]."""
+ hits = [
+ self._create_hit(src_ip="1.2.3.4", ip_rep=""),
+ self._create_hit(src_ip="1.2.3.4", ip_rep="known_attacker"),
+ ]
+ iocs = iocs_from_hits(hits)
+ ioc = iocs[0]
+ self.assertEqual(ioc.ip_reputation, "known_attacker")
+
def test_extracts_timestamps(self):
hits = [
self._create_hit(src_ip="8.8.8.8", timestamp="2025-01-01T10:00:00.000Z"),
@@ -668,6 +694,62 @@ def test_ioc_attacker_country_set_correctly(self):
self.assertEqual(ioc.interaction_count, 1)
+ def test_ioc_attacker_country_code_set_correctly(self):
+ """Verify that iocs_from_hits extracts country_iso_code from geoip."""
+ hits = [
+ self._create_hit(
+ src_ip="8.8.8.8",
+ dest_port=22,
+ hit_type="Cowrie",
+ )
+ ]
+
+ hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NP"}
+
+ iocs = iocs_from_hits(hits)
+ self.assertEqual(len(iocs), 1)
+
+ ioc = iocs[0]
+ self.assertEqual(ioc.attacker_country, "Nepal")
+ self.assertEqual(ioc.attacker_country_code, "NP")
+
+ def test_ioc_attacker_country_code_defaults_to_empty(self):
+ """Verify that attacker_country_code defaults to empty when geoip has no country_iso_code."""
+ hits = [
+ self._create_hit(
+ src_ip="8.8.8.8",
+ dest_port=22,
+ hit_type="Cowrie",
+ )
+ ]
+
+ hits[0]["geoip"] = {"country_name": "Nepal"}
+
+ iocs = iocs_from_hits(hits)
+ self.assertEqual(len(iocs), 1)
+
+ ioc = iocs[0]
+ self.assertEqual(ioc.attacker_country, "Nepal")
+ self.assertEqual(ioc.attacker_country_code, "")
+
+ def test_ioc_attacker_country_code_rejects_invalid_length(self):
+ """Verify that country codes longer than 2 chars are discarded."""
+ hits = [
+ self._create_hit(
+ src_ip="8.8.8.8",
+ dest_port=22,
+ hit_type="Cowrie",
+ )
+ ]
+
+ hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NPL"}
+
+ iocs = iocs_from_hits(hits)
+ self.assertEqual(len(iocs), 1)
+
+ ioc = iocs[0]
+ self.assertEqual(ioc.attacker_country_code, "")
+
def test_ioc_autonomous_system_set_correctly(self):
"""Verify that iocs_from_hits sets autonomous_system FK correctly from hits."""
@@ -703,7 +785,7 @@ def setUp(self):
def _create_mock_payload_request(self):
mock = self._create_mock_ioc()
mock.payload_request = True
- mock.general_honeypot.all.return_value = []
+ mock.honeypots.all.return_value = []
return mock
def test_skips_non_payload_request_iocs(self):
@@ -733,7 +815,7 @@ def test_submits_urls_with_path(self, mock_settings, mock_post):
mock_honeypot_cowrie = Mock()
mock_honeypot_cowrie.name = "Cowrie"
ioc_record = self._create_mock_payload_request()
- ioc_record.general_honeypot.all.return_value = [mock_honeypot_cowrie]
+ ioc_record.honeypots.all.return_value = [mock_honeypot_cowrie]
threatfox_submission(ioc_record, ["http://malicious.com/payload.sh"], self.mock_log)
mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
@@ -752,7 +834,7 @@ def test_includes_honeypot_names_in_comment(self, mock_settings, mock_post):
mock_honeypot_log4pot.name = "Log4pot"
mock_honeypot_dionaea = Mock()
mock_honeypot_dionaea.name = "Dionaea"
- ioc_record.general_honeypot.all.return_value = [mock_honeypot_cowrie, mock_honeypot_log4pot, mock_honeypot_dionaea]
+ ioc_record.honeypots.all.return_value = [mock_honeypot_cowrie, mock_honeypot_log4pot, mock_honeypot_dionaea]
threatfox_submission(ioc_record, ["http://malicious.com/payload.sh"], self.mock_log)
call_kwargs = mock_post.call_args[1]
comment = call_kwargs["json"]["comment"]
diff --git a/tests/test_heralding_strategy.py b/tests/test_heralding_strategy.py
index cf6a6707..02cbc1d7 100644
--- a/tests/test_heralding_strategy.py
+++ b/tests/test_heralding_strategy.py
@@ -43,7 +43,7 @@ def test_extract_scanner_ips(self, mock_threatfox, mock_credential_objects, mock
self.strategy.ioc_processor.add_ioc.assert_any_call(
mock_ioc,
attack_type=SCANNER,
- general_honeypot_name=HERALDING_HONEYPOT,
+ honeypot_name=HERALDING_HONEYPOT,
)
self.assertEqual(len(self.strategy.ioc_records), 1)
mock_threatfox.assert_called_once()
diff --git a/tests/test_ioc_processor.py b/tests/test_ioc_processor.py
index 2dbbe688..1ae5ad61 100644
--- a/tests/test_ioc_processor.py
+++ b/tests/test_ioc_processor.py
@@ -78,24 +78,24 @@ def test_sets_payload_request_flag_for_payload_attack_type(self):
self.assertFalse(result.scanner)
self.assertTrue(result.payload_request)
- def test_adds_general_honeypot_when_provided(self):
+ def test_adds_honeypot_when_provided(self):
self.mock_sensor_repo.cache = {}
self.mock_ioc_repo.get_ioc_by_name.return_value = None
ioc = self._create_mock_ioc()
self.mock_ioc_repo.save.return_value = ioc
self.mock_ioc_repo.add_honeypot_to_ioc.return_value = ioc
- self.processor.add_ioc(ioc, attack_type=SCANNER, general_honeypot_name="TestHoneypot")
+ self.processor.add_ioc(ioc, attack_type=SCANNER, honeypot_name="TestHoneypot")
self.mock_ioc_repo.add_honeypot_to_ioc.assert_called_once_with("TestHoneypot", ioc)
- def test_skips_general_honeypot_when_not_provided(self):
+ def test_skips_honeypot_when_not_provided(self):
self.mock_sensor_repo.cache = {}
self.mock_ioc_repo.get_ioc_by_name.return_value = None
ioc = self._create_mock_ioc()
self.mock_ioc_repo.save.return_value = ioc
- self.processor.add_ioc(ioc, attack_type=SCANNER, general_honeypot_name=None)
+ self.processor.add_ioc(ioc, attack_type=SCANNER, honeypot_name=None)
self.mock_ioc_repo.add_honeypot_to_ioc.assert_not_called()
@@ -322,6 +322,30 @@ def test_clears_stale_firehol_categories(self):
self.assertEqual(result.firehol_categories, [])
+ def test_updates_attacker_country_code(self):
+ existing = self._create_mock_ioc(attacker_country_code="")
+ new = self._create_mock_ioc(attacker_country_code="NP")
+
+ result = self.processor._merge_iocs(existing, new)
+
+ self.assertEqual(result.attacker_country_code, "NP")
+
+ def test_preserves_attacker_country_code_when_new_is_empty(self):
+ existing = self._create_mock_ioc(attacker_country_code="US")
+ new = self._create_mock_ioc(attacker_country_code="")
+
+ result = self.processor._merge_iocs(existing, new)
+
+ self.assertEqual(result.attacker_country_code, "US")
+
+ def test_rejects_invalid_length_attacker_country_code(self):
+ existing = self._create_mock_ioc(attacker_country_code="US")
+ new = self._create_mock_ioc(attacker_country_code="NPL")
+
+ result = self.processor._merge_iocs(existing, new)
+
+ self.assertEqual(result.attacker_country_code, "US")
+
class TestUpdateDaysSeen(ExtractionTestCase):
def setUp(self):
diff --git a/tests/test_ioc_repository.py b/tests/test_ioc_repository.py
index a3260898..d088a6e2 100644
--- a/tests/test_ioc_repository.py
+++ b/tests/test_ioc_repository.py
@@ -5,7 +5,7 @@
from greedybear.cronjobs.repositories import IocRepository
from greedybear.enums import IpReputation
-from greedybear.models import IOC, GeneralHoneypot
+from greedybear.models import IOC, Honeypot
from . import CustomTestCase
@@ -45,14 +45,14 @@ def test_save_updates_existing_ioc(self):
def test_create_honeypot(self):
self.repo.create_honeypot("NewHoneypot")
- self.assertTrue(GeneralHoneypot.objects.filter(name="NewHoneypot").exists())
- hp = GeneralHoneypot.objects.get(name="NewHoneypot")
+ self.assertTrue(Honeypot.objects.filter(name="NewHoneypot").exists())
+ hp = Honeypot.objects.get(name="NewHoneypot")
self.assertTrue(hp.active)
def test_get_active_honeypots_returns_only_active(self):
- GeneralHoneypot.objects.create(name="TestActivePot1", active=True)
- GeneralHoneypot.objects.create(name="TestActivePot2", active=True)
- GeneralHoneypot.objects.create(name="TestInactivePot", active=False)
+ Honeypot.objects.create(name="TestActivePot1", active=True)
+ Honeypot.objects.create(name="TestActivePot2", active=True)
+ Honeypot.objects.create(name="TestInactivePot", active=False)
result = self.repo.get_active_honeypots()
names = [hp.name for hp in result]
@@ -62,15 +62,15 @@ def test_get_active_honeypots_returns_only_active(self):
self.assertNotIn("TestInactivePot", names)
def test_get_active_honeypots_returns_empty_if_none_active(self):
- GeneralHoneypot.objects.update(active=False)
+ Honeypot.objects.update(active=False)
result = self.repo.get_active_honeypots()
self.assertEqual(len(result), 0)
- GeneralHoneypot.objects.update(active=True)
+ Honeypot.objects.update(active=True)
def test_get_hp_by_name_returns_existing(self):
- GeneralHoneypot.objects.create(name="TestPot", active=True)
+ Honeypot.objects.create(name="TestPot", active=True)
result = self.repo.get_hp_by_name("TestPot")
self.assertIsNotNone(result)
self.assertEqual(result.name, "TestPot")
@@ -101,53 +101,53 @@ def test_is_enabled_returns_false_for_inactive_honeypot(self):
def test_add_honeypot_to_ioc_adds_new_honeypot(self):
ioc = IOC.objects.create(name="1.2.3.4", type="ip")
- honeypot = GeneralHoneypot.objects.get(name="Cowrie")
+ honeypot = Honeypot.objects.get(name="Cowrie")
result = self.repo.add_honeypot_to_ioc("Cowrie", ioc)
- self.assertIn(honeypot, result.general_honeypot.all())
+ self.assertIn(honeypot, result.honeypots.all())
def test_add_honeypot_to_ioc_cache_miss_logs_error(self):
"""Honeypot created after repo init is not in cache; association is skipped and error is logged."""
ioc = IOC.objects.create(name="1.2.3.4", type="ip")
# Force cache initialization before creating the new honeypot
_ = self.repo._honeypot_cache
- GeneralHoneypot.objects.create(name="NewPot", active=True)
+ Honeypot.objects.create(name="NewPot", active=True)
with self.assertLogs("greedybear.cronjobs.repositories.ioc", level="ERROR") as cm:
result = self.repo.add_honeypot_to_ioc("NewPot", ioc)
- self.assertEqual(result.general_honeypot.count(), 0)
+ self.assertEqual(result.honeypots.count(), 0)
self.assertTrue(any("NewPot" in msg for msg in cm.output))
def test_add_honeypot_to_ioc_idempotent(self):
ioc = IOC.objects.create(name="1.2.3.4", type="ip")
- honeypot = GeneralHoneypot.objects.get(name="Cowrie")
- ioc.general_honeypot.add(honeypot)
- initial_count = ioc.general_honeypot.count()
+ honeypot = Honeypot.objects.get(name="Cowrie")
+ ioc.honeypots.add(honeypot)
+ initial_count = ioc.honeypots.count()
result = self.repo.add_honeypot_to_ioc("Cowrie", ioc)
- self.assertEqual(result.general_honeypot.count(), initial_count)
- self.assertEqual(ioc.general_honeypot.count(), 1)
+ self.assertEqual(result.honeypots.count(), initial_count)
+ self.assertEqual(ioc.honeypots.count(), 1)
def test_add_honeypot_to_ioc_idempotent_case_insensitive(self):
ioc = IOC.objects.create(name="1.2.3.5", type="ip")
- honeypot = GeneralHoneypot.objects.get(name="Log4pot")
- ioc.general_honeypot.add(honeypot)
+ honeypot = Honeypot.objects.get(name="Log4pot")
+ ioc.honeypots.add(honeypot)
- # Fetch through repository so general_honeypot is prefetched; with normalized
+ # Fetch through repository so honeypots is prefetched; with normalized
# membership check this should not attempt another add query.
ioc_fetched = self.repo.get_ioc_by_name("1.2.3.5")
with self.assertNumQueries(0):
result = self.repo.add_honeypot_to_ioc("Log4Pot", ioc_fetched)
- self.assertEqual(result.general_honeypot.count(), 1)
- self.assertIn(honeypot, result.general_honeypot.all())
+ self.assertEqual(result.honeypots.count(), 1)
+ self.assertIn(honeypot, result.honeypots.all())
def test_add_honeypot_to_ioc_multiple_honeypots(self):
ioc = IOC.objects.create(name="1.2.3.4", type="ip")
- hp1 = GeneralHoneypot.objects.get(name="Cowrie")
- hp2 = GeneralHoneypot.objects.get(name="Log4pot")
+ hp1 = Honeypot.objects.get(name="Cowrie")
+ hp2 = Honeypot.objects.get(name="Log4pot")
self.repo.add_honeypot_to_ioc("Cowrie", ioc)
self.repo.add_honeypot_to_ioc("Log4pot", ioc)
- self.assertEqual(ioc.general_honeypot.count(), 2)
- self.assertIn(hp1, ioc.general_honeypot.all())
- self.assertIn(hp2, ioc.general_honeypot.all())
+ self.assertEqual(ioc.honeypots.count(), 2)
+ self.assertIn(hp1, ioc.honeypots.all())
+ self.assertIn(hp2, ioc.honeypots.all())
def test_existing_honeypots(self):
expected_honeypots = ["Cowrie", "Log4pot", "Heralding", "Ciscoasa", "Ddospot"]
@@ -157,21 +157,21 @@ def test_existing_honeypots(self):
def test_is_ready_for_extraction_creates_and_enables(self):
result = self.repo.is_ready_for_extraction("FooPot")
self.assertTrue(result)
- self.assertTrue(GeneralHoneypot.objects.filter(name="FooPot").exists())
+ self.assertTrue(Honeypot.objects.filter(name="FooPot").exists())
def test_is_ready_for_extraction_case_insensitive(self):
- GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})
+ Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})
result = self.repo.is_ready_for_extraction("cowrie")
self.assertTrue(result)
- self.assertEqual(GeneralHoneypot.objects.filter(name__iexact="cowrie").count(), 1)
+ self.assertEqual(Honeypot.objects.filter(name__iexact="cowrie").count(), 1)
def test_get_hp_by_name_insensitive(self):
- GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})
+ Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})
result = self.repo.get_hp_by_name("cowrie")
self.assertIsNotNone(result)
def test_disabled_honeypot_case_insensitive(self):
- GeneralHoneypot.objects.create(name="Testpot69", active=False)
+ Honeypot.objects.create(name="Testpot69", active=False)
# reiniting repo after DB change to refresh the cache
repo = IocRepository()
@@ -179,7 +179,7 @@ def test_disabled_honeypot_case_insensitive(self):
self.assertFalse(result)
def test_special_and_normal_honeypots(self):
- GeneralHoneypot.objects.create(name="NormalPot", active=False)
+ Honeypot.objects.create(name="NormalPot", active=False)
repo = IocRepository()
@@ -189,29 +189,29 @@ def test_special_and_normal_honeypots(self):
self.assertFalse(repo.is_ready_for_extraction("normalpot"))
def test_create_honeypot_case_insensitive_uniqueness(self):
- initial_count = GeneralHoneypot.objects.count()
- GeneralHoneypot.objects.create(name="TestPot123", active=True)
- self.assertEqual(GeneralHoneypot.objects.count(), initial_count + 1)
+ initial_count = Honeypot.objects.count()
+ Honeypot.objects.create(name="TestPot123", active=True)
+ self.assertEqual(Honeypot.objects.count(), initial_count + 1)
with self.assertRaises(IntegrityError):
with transaction.atomic():
- GeneralHoneypot.objects.create(name="testpot123", active=True)
+ Honeypot.objects.create(name="testpot123", active=True)
- self.assertEqual(GeneralHoneypot.objects.count(), initial_count + 1)
- self.assertEqual(GeneralHoneypot.objects.get(name__iexact="testpot123").name, "TestPot123")
+ self.assertEqual(Honeypot.objects.count(), initial_count + 1)
+ self.assertEqual(Honeypot.objects.get(name__iexact="testpot123").name, "TestPot123")
def test_create_honeypot_integrity_error_handling(self):
- initial_count = GeneralHoneypot.objects.count()
- GeneralHoneypot.objects.create(name="Log4PotTest123", active=True)
+ initial_count = Honeypot.objects.count()
+ Honeypot.objects.create(name="Log4PotTest123", active=True)
try:
with transaction.atomic():
- GeneralHoneypot.objects.create(name="log4pottest123", active=True)
+ Honeypot.objects.create(name="log4pottest123", active=True)
except IntegrityError:
- hp = GeneralHoneypot.objects.filter(name__iexact="log4pottest123").first()
+ hp = Honeypot.objects.filter(name__iexact="log4pottest123").first()
self.assertEqual(hp.name, "Log4PotTest123")
- self.assertEqual(GeneralHoneypot.objects.count(), initial_count + 1)
+ self.assertEqual(Honeypot.objects.count(), initial_count + 1)
def test_create_new_honeypot_creates_and_updates_cache(self):
self.repo._honeypot_cache.clear()
@@ -220,30 +220,30 @@ def test_create_new_honeypot_creates_and_updates_cache(self):
self.assertIn("uniquenewpot123", self.repo._honeypot_cache)
self.assertTrue(hp.active)
- db_hp = GeneralHoneypot.objects.get(name="UniqueNewPot123")
+ db_hp = Honeypot.objects.get(name="UniqueNewPot123")
self.assertEqual(db_hp.name, "UniqueNewPot123")
self.assertTrue(db_hp.active)
def test_honeypot_unique_constraint_case_insensitive(self):
- initial_count = GeneralHoneypot.objects.count()
+ initial_count = Honeypot.objects.count()
hp1 = self.repo.create_honeypot("TestPot456")
self.assertIsNotNone(hp1)
with self.assertRaises(IntegrityError):
with transaction.atomic():
- GeneralHoneypot.objects.create(name="testpot456", active=True)
+ Honeypot.objects.create(name="testpot456", active=True)
- self.assertEqual(GeneralHoneypot.objects.filter(name__iexact="testpot456").count(), 1)
- self.assertEqual(GeneralHoneypot.objects.count(), initial_count + 1)
+ self.assertEqual(Honeypot.objects.filter(name__iexact="testpot456").count(), 1)
+ self.assertEqual(Honeypot.objects.count(), initial_count + 1)
def test_get_scanners_for_scoring_returns_scanners(self):
# Create scanners
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
- log4pot_hp = GeneralHoneypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ log4pot_hp = Honeypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
ioc1 = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True)
- ioc1.general_honeypot.add(cowrie_hp)
+ ioc1.honeypots.add(cowrie_hp)
ioc2 = IOC.objects.create(name="5.6.7.8", type="ip", scanner=True)
- ioc2.general_honeypot.add(log4pot_hp)
+ ioc2.honeypots.add(log4pot_hp)
result = self.repo.get_scanners_for_scoring(["recurrence_probability", "expected_interactions"])
@@ -252,9 +252,9 @@ def test_get_scanners_for_scoring_returns_scanners(self):
self.assertIn("5.6.7.8", names)
def test_get_scanners_for_scoring_excludes_non_scanners(self):
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=False)
- ioc.general_honeypot.add(cowrie_hp)
+ ioc.honeypots.add(cowrie_hp)
result = self.repo.get_scanners_for_scoring(["recurrence_probability"])
@@ -262,9 +262,9 @@ def test_get_scanners_for_scoring_excludes_non_scanners(self):
self.assertNotIn("1.2.3.4", names)
def test_get_scanners_for_scoring_only_loads_specified_fields(self):
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True, attack_count=100)
- ioc.general_honeypot.add(cowrie_hp)
+ ioc.honeypots.add(cowrie_hp)
result = list(self.repo.get_scanners_for_scoring(["recurrence_probability"]))
@@ -289,24 +289,24 @@ def test_get_scanners_by_pks_returns_correct_iocs(self):
self.assertNotIn("9.10.11.12", values)
def test_get_scanners_by_pks_includes_honeypot_annotation(self):
- hp = GeneralHoneypot.objects.create(name="TestPot", active=True)
+ hp = Honeypot.objects.create(name="TestPot", active=True)
ioc = IOC.objects.create(name="1.2.3.4", type="ip")
- ioc.general_honeypot.add(hp)
+ ioc.honeypots.add(hp)
result = list(self.repo.get_scanners_by_pks({ioc.pk}))
self.assertEqual(len(result), 1)
- self.assertIn("honeypots", result[0])
+ self.assertIn("honeypot_names", result[0])
def test_get_recent_scanners_returns_recent_only(self):
recent_date = datetime.now() - timedelta(days=5)
old_date = datetime.now() - timedelta(days=40)
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
ioc1 = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True, last_seen=recent_date)
- ioc1.general_honeypot.add(cowrie_hp)
+ ioc1.honeypots.add(cowrie_hp)
ioc2 = IOC.objects.create(name="5.6.7.8", type="ip", scanner=True, last_seen=old_date)
- ioc2.general_honeypot.add(cowrie_hp)
+ ioc2.honeypots.add(cowrie_hp)
cutoff = datetime.now() - timedelta(days=30)
result = list(self.repo.get_recent_scanners(cutoff, days_lookback=30))
@@ -317,9 +317,9 @@ def test_get_recent_scanners_returns_recent_only(self):
def test_get_recent_scanners_excludes_non_scanners(self):
recent_date = datetime.now() - timedelta(days=5)
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=False, last_seen=recent_date)
- ioc.general_honeypot.add(cowrie_hp)
+ ioc.honeypots.add(cowrie_hp)
cutoff = datetime.now() - timedelta(days=30)
result = list(self.repo.get_recent_scanners(cutoff))
@@ -369,9 +369,9 @@ def test_get_scanners_for_scoring_returns_empty_when_no_scanners(self):
self.assertEqual(len(result), 0)
def test_get_scanners_for_scoring_excludes_inactive_honeypots(self):
- hp = GeneralHoneypot.objects.create(name="InactivePot", active=False)
+ hp = Honeypot.objects.create(name="InactivePot", active=False)
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True)
- ioc.general_honeypot.add(hp)
+ ioc.honeypots.add(hp)
result = list(self.repo.get_scanners_for_scoring(["recurrence_probability"]))
@@ -379,10 +379,10 @@ def test_get_scanners_for_scoring_excludes_inactive_honeypots(self):
self.assertNotIn("1.2.3.4", names)
def test_get_scanners_for_scoring_with_multiple_honeypots(self):
- hp1 = GeneralHoneypot.objects.create(name="Pot1", active=True)
- hp2 = GeneralHoneypot.objects.create(name="Pot2", active=True)
+ hp1 = Honeypot.objects.create(name="Pot1", active=True)
+ hp2 = Honeypot.objects.create(name="Pot2", active=True)
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True)
- ioc.general_honeypot.add(hp1, hp2)
+ ioc.honeypots.add(hp1, hp2)
result = list(self.repo.get_scanners_for_scoring(["recurrence_probability"]))
@@ -406,13 +406,13 @@ def test_get_scanners_by_pks_ioc_with_no_honeypots(self):
result = list(self.repo.get_scanners_by_pks({ioc.pk}))
self.assertEqual(len(result), 1)
- self.assertIn("honeypots", result[0])
+ self.assertIn("honeypot_names", result[0])
def test_get_recent_scanners_all_iocs_older_than_cutoff(self):
old_date = datetime.now() - timedelta(days=40)
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True, last_seen=old_date)
- ioc.general_honeypot.add(cowrie_hp)
+ ioc.honeypots.add(cowrie_hp)
cutoff = datetime.now() - timedelta(days=30)
result = list(self.repo.get_recent_scanners(cutoff))
@@ -421,10 +421,10 @@ def test_get_recent_scanners_all_iocs_older_than_cutoff(self):
self.assertNotIn("1.2.3.4", values)
def test_get_recent_scanners_with_inactive_honeypot(self):
- hp = GeneralHoneypot.objects.create(name="InactivePot", active=False)
+ hp = Honeypot.objects.create(name="InactivePot", active=False)
recent_date = datetime.now() - timedelta(days=5)
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True, last_seen=recent_date)
- ioc.general_honeypot.add(hp)
+ ioc.honeypots.add(hp)
cutoff = datetime.now() - timedelta(days=30)
result = list(self.repo.get_recent_scanners(cutoff))
@@ -449,8 +449,8 @@ def test_bulk_update_scores_with_custom_batch_size(self):
# --- Tests for N+1 fix ---
- def test_honeypot_cache_stores_generalhoneypot_objects(self):
- """honeypot_cache must store GeneralHoneypot instances, not booleans."""
+ def test_honeypot_cache_stores_honeypot_objects(self):
+ """_honeypot_cache must store Honeypot instances, not booleans."""
self.assertGreater(
len(self.repo._honeypot_cache),
0,
@@ -459,29 +459,29 @@ def test_honeypot_cache_stores_generalhoneypot_objects(self):
for key, value in self.repo._honeypot_cache.items():
self.assertIsInstance(
value,
- GeneralHoneypot,
- f"Cache value for '{key}' should be a GeneralHoneypot instance, got {type(value)}",
+ Honeypot,
+ f"Cache value for '{key}' should be a Honeypot instance, got {type(value)}",
)
- def test_get_ioc_by_name_prefetches_general_honeypot(self):
- """Accessing general_honeypot on an IOC fetched via get_ioc_by_name must not trigger extra DB queries."""
+ def test_get_ioc_by_name_prefetches_honeypots(self):
+ """Accessing honeypots on an IOC fetched via get_ioc_by_name must not trigger extra DB queries."""
ioc = self.repo.get_ioc_by_name("140.246.171.141")
self.assertIsNotNone(ioc)
with self.assertNumQueries(0):
- list(ioc.general_honeypot.all())
+ list(ioc.honeypots.all())
def test_add_honeypot_to_ioc_uses_cache_not_db(self):
"""When honeypot is in cache and the IOC was fetched with prefetch, no extra queries are needed."""
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
# Case 1: IOC already associated with Cowrie - membership check uses prefetch (0 queries),
# and skips the add entirely (already associated), producing zero DB queries
ioc = IOC.objects.create(name="5.5.5.5", type="ip")
- ioc.general_honeypot.add(cowrie_hp)
+ ioc.honeypots.add(cowrie_hp)
ioc_fetched = self.repo.get_ioc_by_name("5.5.5.5")
with self.assertNumQueries(0):
result = self.repo.add_honeypot_to_ioc("Cowrie", ioc_fetched)
- self.assertIn(cowrie_hp, result.general_honeypot.all())
+ self.assertIn(cowrie_hp, result.honeypots.all())
# Case 2: IOC not yet associated - membership check uses prefetch (0 queries),
# honeypot lookup uses in-memory cache (0 queries), only the M2M INSERT fires
@@ -490,13 +490,13 @@ def test_add_honeypot_to_ioc_uses_cache_not_db(self):
_ = self.repo._honeypot_cache # Force cache load to isolate M2M INSERT queries
with self.assertNumQueries(1): # only M2M INSERT
result2 = self.repo.add_honeypot_to_ioc("Cowrie", ioc2_fetched)
- self.assertIn(cowrie_hp, result2.general_honeypot.all())
+ self.assertIn(cowrie_hp, result2.honeypots.all())
def test_create_honeypot_stores_object_in_cache(self):
- """create_honeypot must store the GeneralHoneypot object in cache, not a boolean."""
+ """create_honeypot must store the Honeypot object in cache, not a boolean."""
hp = self.repo.create_honeypot("CacheTestPot")
cached = self.repo._honeypot_cache.get("cachetestpot")
- self.assertIsInstance(cached, GeneralHoneypot)
+ self.assertIsInstance(cached, Honeypot)
self.assertEqual(cached.pk, hp.pk)
@@ -513,12 +513,12 @@ def test_update_scores_with_repository(self):
from greedybear.cronjobs.scoring.scoring_jobs import UpdateScores
# Create test data
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
- log4pot_hp = GeneralHoneypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ log4pot_hp = Honeypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
ioc1 = IOC.objects.create(name="10.1.2.3", type="ip", scanner=True, recurrence_probability=0.0)
- ioc1.general_honeypot.add(cowrie_hp)
+ ioc1.honeypots.add(cowrie_hp)
ioc2 = IOC.objects.create(name="10.5.6.7", type="ip", scanner=True, recurrence_probability=0.0)
- ioc2.general_honeypot.add(log4pot_hp)
+ ioc2.honeypots.add(log4pot_hp)
# Create score dataframe
df = pd.DataFrame(
@@ -547,12 +547,12 @@ def test_update_scores_resets_missing_iocs(self):
from greedybear.cronjobs.scoring.scoring_jobs import UpdateScores
# Create test data - one IOC will be missing from df
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
- log4pot_hp = GeneralHoneypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ log4pot_hp = Honeypot.objects.get_or_create(name="Log4pot", defaults={"active": True})[0]
ioc1 = IOC.objects.create(name="10.2.3.4", type="ip", scanner=True, recurrence_probability=0.9)
- ioc1.general_honeypot.add(cowrie_hp)
+ ioc1.honeypots.add(cowrie_hp)
ioc2 = IOC.objects.create(name="10.6.7.8", type="ip", scanner=True, recurrence_probability=0.8)
- ioc2.general_honeypot.add(log4pot_hp)
+ ioc2.honeypots.add(log4pot_hp)
# DataFrame only has one IOC
df = pd.DataFrame({"value": ["10.2.3.4"], "recurrence_probability": [0.75], "expected_interactions": [10.0]})
@@ -571,9 +571,9 @@ def test_get_current_data_with_repository(self):
from greedybear.cronjobs.scoring.utils import get_current_data
recent_date = datetime.now() - timedelta(days=5)
- cowrie_hp = GeneralHoneypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
+ cowrie_hp = Honeypot.objects.get_or_create(name="Cowrie", defaults={"active": True})[0]
ioc = IOC.objects.create(name="1.2.3.4", type="ip", scanner=True, last_seen=recent_date)
- ioc.general_honeypot.add(cowrie_hp)
+ ioc.honeypots.add(cowrie_hp)
result = get_current_data(days_lookback=30, ioc_repo=self.repo)
diff --git a/tests/test_models.py b/tests/test_models.py
index 18be2556..bbfbc78f 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -17,8 +17,8 @@ def test_ioc_model(self):
self.assertEqual(self.ioc.attack_count, 1)
self.assertEqual(self.ioc.interaction_count, 1)
# Honeypots are now via M2M relationship
- self.assertIn(self.cowrie_hp, self.ioc.general_honeypot.all())
- self.assertIn(self.log4pot_hp, self.ioc.general_honeypot.all())
+ self.assertIn(self.cowrie_hp, self.ioc.honeypots.all())
+ self.assertIn(self.log4pot_hp, self.ioc.honeypots.all())
self.assertEqual(self.ioc.scanner, True)
self.assertEqual(self.ioc.payload_request, True)
self.assertEqual(self.ioc.related_urls, [])
@@ -31,8 +31,8 @@ def test_ioc_model(self):
self.assertEqual(self.ioc_2.ip_reputation, IpReputation.MASS_SCANNER)
- self.assertIn(self.heralding, self.ioc.general_honeypot.all())
- self.assertIn(self.ciscoasa, self.ioc.general_honeypot.all())
+ self.assertIn(self.heralding, self.ioc.honeypots.all())
+ self.assertIn(self.ciscoasa, self.ioc.honeypots.all())
def test_command_sequence_model(self):
self.assertEqual(self.command_sequence.first_seen, self.current_time)
@@ -71,7 +71,7 @@ def test_statistics_model(self):
self.assertEqual(self.statistic.view, ViewType.ENRICHMENT_VIEW.value)
self.assertEqual(self.statistic.request_date, self.current_time)
- def test_general_honeypot_model(self):
+ def test_honeypot_model(self):
self.assertEqual(self.heralding.name, "Heralding")
self.assertEqual(self.heralding.active, True)
diff --git a/tests/test_serializers.py b/tests/test_serializers.py
index 6e78cdc5..e367cd58 100644
--- a/tests/test_serializers.py
+++ b/tests/test_serializers.py
@@ -11,7 +11,7 @@
)
from greedybear.consts import PAYLOAD_REQUEST, SCANNER
from greedybear.enums import IpReputation
-from greedybear.models import IOC, GeneralHoneypot, Sensor
+from greedybear.models import IOC, Honeypot, Sensor
from tests import CustomTestCase
@@ -39,9 +39,9 @@ class FeedsRequestSerializersTestCase(CustomTestCase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
- cls.adbhoney = GeneralHoneypot.objects.filter(name__iexact="adbhoney").first()
+ cls.adbhoney = Honeypot.objects.filter(name__iexact="adbhoney").first()
if not cls.adbhoney:
- cls.adbhoney = GeneralHoneypot.objects.create(name="Adbhoney", active=True)
+ cls.adbhoney = Honeypot.objects.create(name="Adbhoney", active=True)
def test_valid_fields(self):
choices = {
@@ -218,9 +218,9 @@ class FeedsResponseSerializersTestCase(CustomTestCase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
- cls.adbhoney = GeneralHoneypot.objects.filter(name__iexact="adbhoney").first()
+ cls.adbhoney = Honeypot.objects.filter(name__iexact="adbhoney").first()
if not cls.adbhoney:
- cls.adbhoney = GeneralHoneypot.objects.create(name="Adbhoney", active=True)
+ cls.adbhoney = Honeypot.objects.create(name="Adbhoney", active=True)
def test_valid_fields(self):
scanner_choices = [True, False]
diff --git a/tests/test_tanner_strategy.py b/tests/test_tanner_strategy.py
index 7e31f95c..729fd37c 100644
--- a/tests/test_tanner_strategy.py
+++ b/tests/test_tanner_strategy.py
@@ -30,7 +30,7 @@ def test_extract_scanner_ips(self, mock_threatfox, mock_iocs_from_hits):
self.strategy.extract_from_hits(hits)
mock_iocs_from_hits.assert_called_once_with(hits)
- self.strategy.ioc_processor.add_ioc.assert_any_call(mock_ioc, attack_type=SCANNER, general_honeypot_name=TANNER_HONEYPOT)
+ self.strategy.ioc_processor.add_ioc.assert_any_call(mock_ioc, attack_type=SCANNER, honeypot_name=TANNER_HONEYPOT)
self.assertEqual(len(self.strategy.ioc_records), 1)
mock_threatfox.assert_called_once()
@@ -373,7 +373,7 @@ def test_rfi_hostname_as_payload_request(self, mock_add_tags, mock_threatfox, mo
payload_calls = [call for call in self.strategy.ioc_processor.add_ioc.call_args_list if call[1].get("attack_type") == PAYLOAD_REQUEST]
self.assertEqual(len(payload_calls), 1)
self.assertEqual(payload_calls[0][0][0].name, "evil.com")
- self.assertEqual(payload_calls[0][1]["general_honeypot_name"], TANNER_HONEYPOT)
+ self.assertEqual(payload_calls[0][1]["honeypot_name"], TANNER_HONEYPOT)
@patch("greedybear.cronjobs.extraction.strategies.tanner.iocs_from_hits")
@patch("greedybear.cronjobs.extraction.strategies.tanner.threatfox_submission")
diff --git a/uv.lock b/uv.lock
index 92a075a7..1aef058a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -483,7 +483,7 @@ wheels = [
[[package]]
name = "greedybear"
-version = "3.3.0"
+version = "3.3.1"
source = { virtual = "." }
dependencies = [
{ name = "certego-saas" },
@@ -500,7 +500,7 @@ dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "pandas" },
- { name = "psycopg2-binary" },
+ { name = "psycopg", extra = ["c"] },
{ name = "requests" },
{ name = "scikit-learn" },
{ name = "slack-sdk" },
@@ -529,12 +529,12 @@ requires-dist = [
{ name = "djangorestframework", specifier = "==3.17.1" },
{ name = "elasticsearch", specifier = "==9.3.0" },
{ name = "feedparser", specifier = "==6.0.12" },
- { name = "gunicorn", specifier = "==25.2.0" },
+ { name = "gunicorn", specifier = "==25.3.0" },
{ name = "joblib", specifier = "==1.5.3" },
- { name = "numpy", specifier = "==2.4.3" },
- { name = "pandas", specifier = "==3.0.1" },
- { name = "psycopg2-binary", specifier = "==2.9.11" },
- { name = "requests", specifier = "==2.33.0" },
+ { name = "numpy", specifier = "==2.4.4" },
+ { name = "pandas", specifier = "==3.0.2" },
+ { name = "psycopg", extras = ["c"], specifier = "==3.3.3" },
+ { name = "requests", specifier = "==2.33.1" },
{ name = "scikit-learn", specifier = "==1.8.0" },
{ name = "slack-sdk", specifier = "==3.41.0" },
{ name = "stix2", specifier = "==3.0.2" },
@@ -546,18 +546,18 @@ dev = [
{ name = "django-test-migrations", specifier = "==1.5.0" },
{ name = "django-watchfiles", specifier = "==1.4.0" },
]
-lint = [{ name = "ruff", specifier = "==0.15.8" }]
+lint = [{ name = "ruff", specifier = "==0.15.9" }]
[[package]]
name = "gunicorn"
-version = "25.2.0"
+version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/dd/13/dd3f8e40ea3ee907a6cbf3d1f1f81afcc3ecd0087d313baabfe95372f15c/gunicorn-25.2.0.tar.gz", hash = "sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c", size = 632709, upload-time = "2026-03-24T22:49:54.433Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/11/53/fb024445837e02cd5cf989cf349bfac6f3f433c05184ea5d49c8ade751c6/gunicorn-25.2.0-py3-none-any.whl", hash = "sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc", size = 211659, upload-time = "2026-03-24T22:49:52.528Z" },
+ { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
]
[[package]]
@@ -643,31 +643,31 @@ wheels = [
[[package]]
name = "numpy"
-version = "2.4.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
- { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
- { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
- { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
- { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
- { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
- { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
- { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
- { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
- { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
- { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
- { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
- { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
- { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
- { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
- { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
- { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
- { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
- { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
- { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
+version = "2.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
+ { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
+ { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
+ { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
+ { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
]
[[package]]
@@ -690,51 +690,55 @@ wheels = [
[[package]]
name = "pandas"
-version = "3.0.1"
+version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" },
- { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" },
- { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" },
- { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" },
- { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" },
- { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" },
- { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" },
- { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" },
- { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" },
- { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" },
- { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" },
- { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" },
- { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" },
- { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" },
- { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" },
+ { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" },
+ { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" },
+ { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" },
+ { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" },
+ { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" },
]
[[package]]
-name = "psycopg2-binary"
-version = "2.9.11"
+name = "psycopg"
+version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
- { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
- { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
- { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
- { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
- { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
- { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
- { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
- { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
- { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]
+[package.optional-dependencies]
+c = [
+ { name = "psycopg-c", marker = "implementation_name != 'pypy'" },
+]
+
+[[package]]
+name = "psycopg-c"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/a0/8feb0ca8c7c20a8b9ac4d46b335ddd57e48e593b714262f006880f34fee5/psycopg_c-3.3.3.tar.gz", hash = "sha256:86ef6f4424348247828e83fb0882c9f8acb33e64d0a5ce66c1b4a5107ee73edd", size = 631965, upload-time = "2026-02-18T16:52:18.084Z" }
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -812,7 +816,7 @@ wheels = [
[[package]]
name = "requests"
-version = "2.33.0"
+version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -820,9 +824,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
@@ -877,27 +881,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.15.8"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
- { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
- { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
- { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
- { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
- { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
- { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
- { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
- { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
- { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
- { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
- { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
- { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
- { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
- { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
- { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
- { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
+version = "0.15.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
+ { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
+ { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
+ { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
+ { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
+ { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
+ { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
+ { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
+ { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
+ { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
]
[[package]]