({
Geographies: ({ children }) =>
children({
geographies: [
- { rsmKey: "geo-cn", properties: { name: "China" } },
- { rsmKey: "geo-usa", properties: { name: "United States of America" } },
- { rsmKey: "geo-fr", properties: { name: "France" } },
+ { rsmKey: "geo-cn", id: "156", properties: { name: "China" } },
+ {
+ rsmKey: "geo-usa",
+ id: "840",
+ properties: { name: "United States of America" },
+ },
+ { rsmKey: "geo-fr", id: "250", properties: { name: "France" } },
],
}),
Geography: ({ fill, onMouseEnter, onMouseLeave, geography }) => (
@@ -43,15 +50,15 @@ vi.mock("react-simple-maps", () => ({
}));
const COUNTRIES_DATA = [
- { country: "China", count: 120 },
- { country: "United States", count: 80 },
- { country: "Germany", count: 40 },
+ { country: "China", code: "CN", count: 120 },
+ { country: "United States", code: "US", count: 80 },
+ { country: "Germany", code: "DE", count: 40 },
];
describe("AttackOriginMap", () => {
beforeEach(() => {
useAttackerCountriesStore.setState({
- rawData: [],
+ normalizedData: [],
countryDataMap: {},
maxCount: 0,
loading: false,
@@ -127,15 +134,17 @@ describe("AttackOriginMap", () => {
await waitFor(() =>
expect(screen.getByTestId("geography-geo-fr")).toBeInTheDocument(),
);
- // France is not in COUNTRIES_DATA so it must receive the empty/default colour
+ // France (id="250") is not in COUNTRIES_DATA so it must receive the empty/default colour
const franceEl = screen.getByTestId("geography-geo-fr");
expect(franceEl.dataset.fill).toBe("#2a2a3a");
- // China IS in the data so it must not receive the empty colour
+ // China (id="156") IS in the data so it must not receive the empty colour
const chinaEl = screen.getByTestId("geography-geo-cn");
expect(chinaEl.dataset.fill).not.toBe("#2a2a3a");
});
- test("country name normalisation is applied (United States => United States of America)", async () => {
+ test("geo.id numeric lookup correctly colours a country regardless of API name variant", async () => {
+ // The API returns "United States" but the map looks up by geo.id="840" → alpha-2 "US"
+ // so the geography is coloured even though the name doesn't match the TopoJSON name
axios.get.mockResolvedValue({ data: COUNTRIES_DATA });
render(
);
await waitFor(() =>
diff --git a/frontend/tests/components/feeds/Feeds.test.jsx b/frontend/tests/components/feeds/Feeds.test.jsx
index c53d6b176..0dd86a863 100644
--- a/frontend/tests/components/feeds/Feeds.test.jsx
+++ b/frontend/tests/components/feeds/Feeds.test.jsx
@@ -113,7 +113,7 @@ describe("Feeds component", () => {
});
expect(buttonFeedsLicense).toHaveAttribute(
"href",
- "https://github.com/intelowlproject/GreedyBear/blob/main/FEEDS_LICENSE.md",
+ "https://github.com/GreedyBear-Project/GreedyBear/blob/main/FEEDS_LICENSE.md",
);
const feedTypeSelectElement = screen.getByLabelText("Feed type:");
diff --git a/frontend/tests/stores/useAttackerCountriesStore.test.jsx b/frontend/tests/stores/useAttackerCountriesStore.test.jsx
index e02932698..a040db5f3 100644
--- a/frontend/tests/stores/useAttackerCountriesStore.test.jsx
+++ b/frontend/tests/stores/useAttackerCountriesStore.test.jsx
@@ -45,8 +45,8 @@ describe("useAttackerCountriesStore", () => {
const mockRange = "24h";
const rangeStr = JSON.stringify(mockRange);
const mockData = [
- { country: "United States", count: 100 },
- { country: "Italy", count: 50 },
+ { country: "United States", code: "US", count: 100 },
+ { country: "Italy", code: "IT", count: 50 },
];
test("successfully fetches and normalizes data", async () => {
@@ -55,13 +55,14 @@ describe("useAttackerCountriesStore", () => {
await useAttackerCountriesStore.getState().fetchData(mockRange);
const state = useAttackerCountriesStore.getState();
+ // countryDataMap is keyed by alpha-2; normalizedData uses raw API name
expect(state.normalizedData).toEqual([
- { country: "United States of America", count: 100 },
- { country: "Italy", count: 50 },
+ { country: "United States", code: "US", count: 100 },
+ { country: "Italy", code: "IT", count: 50 },
]);
expect(state.countryDataMap).toEqual({
- "United States of America": 100,
- Italy: 50,
+ US: 100,
+ IT: 50,
});
expect(state.maxCount).toBe(100);
expect(state.loading).toBe(false);
@@ -144,8 +145,49 @@ describe("useAttackerCountriesStore", () => {
expect(axios.get).toHaveBeenCalledTimes(2);
expect(useAttackerCountriesStore.getState().normalizedData).toEqual([
- { country: "United States of America", count: 100 },
- { country: "Italy", count: 50 },
+ { country: "United States", code: "US", count: 100 },
+ { country: "Italy", code: "IT", count: 50 },
+ ]);
+ });
+
+ test("aggregates data correctly when same ISO code has different names", async () => {
+ const complexMockData = [
+ { country: "United States", code: "US", count: 100 },
+ { country: "USA", code: "US", count: 50 },
+ { country: "Italy", code: "IT", count: 30 },
+ ];
+ axios.get.mockResolvedValue({ data: complexMockData });
+
+ await useAttackerCountriesStore.getState().fetchData("all");
+
+ const state = useAttackerCountriesStore.getState();
+ // Aggregated by alpha-2; display name is the first-seen value
+ expect(state.normalizedData).toEqual([
+ { country: "United States", code: "US", count: 150 },
+ { country: "Italy", code: "IT", count: 30 },
+ ]);
+ expect(state.countryDataMap).toEqual({
+ US: 150,
+ IT: 30,
+ });
+ expect(state.maxCount).toBe(150);
+ });
+
+ test("skips items that have no ISO code", async () => {
+ const mixedData = [
+ { country: "United States", code: "US", count: 100 },
+ { country: "Unknown", code: null, count: 50 },
+ { country: "Italy", code: "IT", count: 30 },
+ ];
+ axios.get.mockResolvedValue({ data: mixedData });
+
+ await useAttackerCountriesStore.getState().fetchData(mockRange);
+
+ const state = useAttackerCountriesStore.getState();
+ // The entry with no code is excluded
+ expect(state.normalizedData).toEqual([
+ { country: "United States", code: "US", count: 100 },
+ { country: "Italy", code: "IT", count: 30 },
]);
});
@@ -191,8 +233,8 @@ describe("useAttackerCountriesStore", () => {
// Now loading should be false
expect(useAttackerCountriesStore.getState().loading).toBe(false);
expect(useAttackerCountriesStore.getState().normalizedData).toEqual([
- { country: "United States of America", count: 100 },
- { country: "Italy", count: 50 },
+ { country: "United States", code: "US", count: 100 },
+ { country: "Italy", code: "IT", count: 50 },
]);
});
});
diff --git a/frontend/tests/utils/country.test.js b/frontend/tests/utils/country.test.js
deleted file mode 100644
index 8457b8308..000000000
--- a/frontend/tests/utils/country.test.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { normalizeCountryName } from "../../src/utils/country";
-
-describe("normalizeCountryName", () => {
- it("should normalize known mismatched names", () => {
- expect(normalizeCountryName("United States")).toBe(
- "United States of America",
- );
- expect(normalizeCountryName("Czech Republic")).toBe("Czechia");
- expect(normalizeCountryName("Ivory Coast")).toBe("Côte d'Ivoire");
- expect(normalizeCountryName("South Sudan")).toBe("S. Sudan");
- });
-
- it("should return the same name if no mismatch is known", () => {
- expect(normalizeCountryName("Italy")).toBe("Italy");
- expect(normalizeCountryName("Brazil")).toBe("Brazil");
- expect(normalizeCountryName("France")).toBe("France");
- });
-
- it("should handle edge cases like empty strings or nulls", () => {
- expect(normalizeCountryName("")).toBe("");
- expect(normalizeCountryName(null)).toBe(null);
- expect(normalizeCountryName(undefined)).toBe(undefined);
- });
-});
diff --git a/greedybear/admin.py b/greedybear/admin.py
index 3372630f2..96211f8a1 100644
--- a/greedybear/admin.py
+++ b/greedybear/admin.py
@@ -8,6 +8,7 @@
from greedybear.models import (
IOC,
+ AttackerActivityBucket,
CommandSequence,
CowrieSession,
Credential,
@@ -183,6 +184,16 @@ def get_queryset(self, request):
return super().get_queryset(request).select_related("autonomous_system").prefetch_related("sensors", "honeypots")
+@admin.register(AttackerActivityBucket)
+class AttackerActivityBucketAdmin(admin.ModelAdmin):
+ list_display = ["attacker_ip", "feed_type", "bucket_start", "interaction_count"]
+ list_filter = ["feed_type"]
+ search_fields = ["attacker_ip"]
+ search_help_text = "search for the attacker IP address"
+ date_hierarchy = "bucket_start"
+ ordering = ["-bucket_start"]
+
+
@admin.register(Honeypot)
class HoneypotAdmin(admin.ModelAdmin):
list_display = [
diff --git a/greedybear/consts.py b/greedybear/consts.py
index 5ed7f0d5a..a120418cb 100644
--- a/greedybear/consts.py
+++ b/greedybear/consts.py
@@ -15,23 +15,29 @@
DOMAIN = "domain"
IP = "ip"
-REQUIRED_FIELDS = [
+FIELDS_TO_EXTRACT = [
"@timestamp",
- "src_ip",
+ "body",
"dest_port",
- "ip_rep",
+ "duration",
+ "eventid",
"geoip",
- "url",
+ "geoip_ext",
+ "ip_rep",
"message",
- "eventid",
+ "outfile",
+ "password",
+ "path",
+ "post_data",
+ "protocol",
"session",
+ "shasum",
+ "src_ip",
+ "t-pot_ip_ext",
"timestamp",
- "duration",
+ "type",
+ "url",
"username",
- "password",
- "t-pot_ip_ext",
- "shasum",
- "outfile",
]
@@ -53,7 +59,7 @@
# we used this const to implement news feature
-RSS_FEED_URL = "https://intelowlproject.github.io/feed.xml"
+RSS_FEED_URL = "https://greedybear-project.github.io/feed.xml"
CACHE_KEY_GREEDYBEAR_NEWS = "greedybear_news"
CACHE_TIMEOUT_SECONDS = 60 * 60
diff --git a/greedybear/cronjobs/bucket_cleanup.py b/greedybear/cronjobs/bucket_cleanup.py
new file mode 100644
index 000000000..90c93d4f8
--- /dev/null
+++ b/greedybear/cronjobs/bucket_cleanup.py
@@ -0,0 +1,56 @@
+from datetime import timedelta
+
+from django.conf import settings
+from django.utils import timezone
+
+from greedybear.cronjobs.base import Cronjob
+from greedybear.cronjobs.repositories import TrendingBucketRepository
+
+DEFAULT_TRENDING_MAX_WINDOW_MINUTES = (24 * 31 * 60) // 2
+DEFAULT_TRENDING_BUCKET_RETENTION_HOURS = 24 * 31
+
+
+class TrendingBucketCleanupCron(Cronjob):
+ @staticmethod
+ def _positive_int_setting(name: str, value) -> int:
+ try:
+ parsed_value = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"{name} must be a positive integer, got {value!r}") from exc
+
+ if parsed_value < 1:
+ raise ValueError(f"{name} must be >= 1, got {parsed_value}")
+
+ return parsed_value
+
+ def _validated_settings(self) -> tuple[int, int]:
+ max_window_minutes = self._positive_int_setting(
+ "TRENDING_MAX_WINDOW_MINUTES",
+ getattr(settings, "TRENDING_MAX_WINDOW_MINUTES", DEFAULT_TRENDING_MAX_WINDOW_MINUTES),
+ )
+ if max_window_minutes < 60:
+ raise ValueError(f"TRENDING_MAX_WINDOW_MINUTES must be >= 60, got {max_window_minutes}")
+ if max_window_minutes % 60:
+ raise ValueError(f"TRENDING_MAX_WINDOW_MINUTES must be a multiple of 60, got {max_window_minutes}")
+
+ retention_hours = self._positive_int_setting(
+ "TRENDING_BUCKET_RETENTION_HOURS",
+ getattr(settings, "TRENDING_BUCKET_RETENTION_HOURS", DEFAULT_TRENDING_BUCKET_RETENTION_HOURS),
+ )
+
+ retention_minutes = retention_hours * 60
+ required_retention_minutes = 2 * max_window_minutes
+ if retention_minutes < required_retention_minutes:
+ raise ValueError(
+ "TRENDING_BUCKET_RETENTION_HOURS must retain at least two windows "
+ f"for TRENDING_MAX_WINDOW_MINUTES={max_window_minutes}: "
+ f"required >= {required_retention_minutes} minutes, got {retention_minutes}"
+ )
+
+ return max_window_minutes, retention_hours
+
+ def run(self) -> None:
+ now = timezone.now().replace(minute=0, second=0, microsecond=0)
+ _, retention_hours = self._validated_settings()
+ cutoff = now - timedelta(hours=retention_hours)
+ TrendingBucketRepository().delete_older_than(cutoff)
diff --git a/greedybear/cronjobs/extraction/bucket_updater.py b/greedybear/cronjobs/extraction/bucket_updater.py
new file mode 100644
index 000000000..dd57a07c0
--- /dev/null
+++ b/greedybear/cronjobs/extraction/bucket_updater.py
@@ -0,0 +1,67 @@
+import logging
+from collections import Counter
+from collections.abc import Iterable
+from datetime import datetime
+from ipaddress import ip_address
+
+from greedybear.cronjobs.extraction.utils import parse_timestamp
+from greedybear.cronjobs.repositories import TrendingBucketRepository
+from greedybear.utils import is_non_global_ip
+
+logger = logging.getLogger(__name__)
+
+BucketKey = tuple[str, str, datetime]
+
+
+class BucketUpdater:
+ def __init__(self):
+ self.counters: Counter[BucketKey] = Counter()
+ self.total_update_count: int = 0
+
+ def collect_hits(self, hits: Iterable[dict]) -> None:
+ for hit in hits:
+ key = _bucket_key_from_hit(hit)
+ if key is not None:
+ self.counters[key] += 1
+
+ def update(self) -> int:
+ if not self.counters:
+ return 0
+
+ try:
+ update_count = TrendingBucketRepository().upsert_bucket_counts(self.counters)
+ logger.debug(f"Updated {update_count} buckets")
+ self.total_update_count += update_count
+ return update_count
+ except Exception as exc:
+ logger.error("Failed to update activity buckets from hits for current chunk: %s", exc, exc_info=True)
+ return 0
+ finally:
+ self.counters = Counter()
+
+
+def _bucket_start(timestamp: str) -> datetime:
+ parsed = parse_timestamp(timestamp)
+ return parsed.replace(minute=0, second=0, microsecond=0)
+
+
+def _bucket_key_from_hit(hit: dict) -> BucketKey | None:
+ attacker_ip = hit.get("src_ip")
+ feed_type = hit.get("type")
+ timestamp = hit.get("@timestamp")
+ if not attacker_ip or not feed_type or not timestamp:
+ return None
+
+ normalized_ip = str(attacker_ip)
+ try:
+ parsed_ip = ip_address(normalized_ip)
+ except ValueError:
+ return None
+
+ if is_non_global_ip(parsed_ip):
+ return None
+
+ try:
+ return normalized_ip, str(feed_type).lower(), _bucket_start(timestamp)
+ except Exception:
+ return None
diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py
index f434bd83d..a7e3e64a3 100644
--- a/greedybear/cronjobs/extraction/pipeline.py
+++ b/greedybear/cronjobs/extraction/pipeline.py
@@ -3,6 +3,7 @@
from django.core.cache import caches
+from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater
from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory
from greedybear.cronjobs.repositories import (
ElasticRepository,
@@ -52,11 +53,13 @@ def execute(self) -> int:
2. For each chunk, group hits by honeypot type and extract sensors
3. Apply honeypot-specific extraction strategies
4. Update IOC scores
+ 5. Update activity buckets
Returns:
Number of IOC records processed.
"""
ioc_record_count = 0
+ bucket_updater = BucketUpdater()
factory = ExtractionStrategyFactory(self.ioc_repo, self.sensor_repo)
# 1. Search in chunks
@@ -68,30 +71,34 @@ def execute(self) -> int:
# 2. Group by honeypot
self.log.info("Grouping hits by honeypot type")
for hit in chunk:
+ # convert hit to dict for easier handling
+ hit = hit.to_dict()
# skip hits with non-existing or empty sources
if "src_ip" not in hit or not hit["src_ip"].strip():
continue
# skip hits with non-existing or empty types (=honeypots)
if "type" not in hit or not hit["type"].strip():
continue
- # extract sensor and include in hit dict
- hit_dict = hit.to_dict()
if "t-pot_ip_ext" in hit:
sensor = self.sensor_repo.get_or_create_sensor(hit["t-pot_ip_ext"])
- hit_dict["_sensor"] = sensor # include sensor for strategies
+ hit["_sensor"] = sensor # include sensor for strategies
- sensor_country = hit_dict.get("geoip_ext", {}).get("country_name")
+ sensor_country = hit.get("geoip_ext", {}).get("country_name")
if sensor_country is not None:
self.sensor_repo.update_country(sensor, sensor_country)
- hits_by_honeypot[hit["type"]].append(hit_dict)
+ hits_by_honeypot[hit["type"]].append(hit)
# 3. Extract using strategies
for honeypot, hits in sorted(hits_by_honeypot.items()):
if not self.ioc_repo.is_ready_for_extraction(honeypot):
self.log.info(f"Skipping honeypot {honeypot}")
continue
+
+ self.log.info(f"Collect hits for activity buckets from honeypot {honeypot}")
+ bucket_updater.collect_hits(hits)
+
self.log.info(f"Extracting hits from honeypot {honeypot}")
strategy = factory.get_strategy(honeypot)
try:
@@ -106,7 +113,11 @@ def execute(self) -> int:
UpdateScores().score_only(ioc_records)
ioc_record_count += len(ioc_records)
- # 5. Invalidate API caches only if any IOC records were processed
+ # 5. Update activity buckets
+ self.log.info("Updating activity buckets")
+ bucket_updater.update()
+
+ # 6. Invalidate API caches only if any IOC records were processed
if ioc_record_count > 0:
# Use the shared DB-backed cache so the version bump is visible to
# gunicorn API workers (LocMemCache is per-process).
@@ -117,4 +128,12 @@ def execute(self) -> int:
except ValueError:
shared_cache.set("asn_feeds_version", 2, timeout=None)
+ if bucket_updater.total_update_count > 0:
+ self.log.info("Invalidating feeds trending cache")
+ shared_cache = caches["django-q"]
+ try:
+ shared_cache.incr("trending_feeds_version")
+ except ValueError:
+ shared_cache.set("trending_feeds_version", 2, timeout=None)
+
return ioc_record_count
diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py
index 62e2ca961..bdd002d3c 100644
--- a/greedybear/cronjobs/extraction/utils.py
+++ b/greedybear/cronjobs/extraction/utils.py
@@ -9,7 +9,7 @@
from greedybear.cronjobs.repositories import ASRepository
from greedybear.enums import IpReputation
from greedybear.models import IOC, FireHolList, MassScanner
-from greedybear.utils import get_ioc_type, parse_timestamp
+from greedybear.utils import get_ioc_type, is_non_global_ip, parse_timestamp
def normalize_credential_field(value: object, max_length: int = 256) -> str:
@@ -135,7 +135,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]:
as_repository = ASRepository() # single instance for this batch
for ip, hits in hits_by_ip.items():
extracted_ip = ip_address(ip)
- if extracted_ip.is_loopback or extracted_ip.is_private or extracted_ip.is_multicast or extracted_ip.is_link_local or extracted_ip.is_reserved:
+ if is_non_global_ip(extracted_ip):
continue
firehol_categories = get_firehol_categories(ip, extracted_ip, firehol_exact_map, cidr_entries)
@@ -161,7 +161,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]:
geoip = next((h.get("geoip") for h in hits if h.get("geoip")), {})
attacker_country = geoip.get("country_name", "")
- raw_country_code = geoip.get("country_iso_code", "")
+ raw_country_code = geoip.get("country_code2", "")
attacker_country_code = raw_country_code if len(raw_country_code) == 2 else ""
asn = geoip.get("asn")
diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py
index 337eac0c8..39d9bf07a 100644
--- a/greedybear/cronjobs/repositories/__init__.py
+++ b/greedybear/cronjobs/repositories/__init__.py
@@ -7,3 +7,4 @@
from greedybear.cronjobs.repositories.sensor import *
from greedybear.cronjobs.repositories.tag import *
from greedybear.cronjobs.repositories.tor import *
+from greedybear.cronjobs.repositories.trending_bucket import *
diff --git a/greedybear/cronjobs/repositories/elastic.py b/greedybear/cronjobs/repositories/elastic.py
index 0ca0716ff..55686ee2d 100644
--- a/greedybear/cronjobs/repositories/elastic.py
+++ b/greedybear/cronjobs/repositories/elastic.py
@@ -5,7 +5,7 @@
from django.conf import settings
from elasticsearch.dsl import Q, Search
-from greedybear.consts import REQUIRED_FIELDS
+from greedybear.consts import FIELDS_TO_EXTRACT
from greedybear.settings import EXTRACTION_INTERVAL
@@ -56,7 +56,7 @@ def search(self, minutes_back_to_lookup: int) -> Iterator[list]:
minutes_back_to_lookup: Number of minutes to look back from the current time.
Yields:
- list: Log entries sorted by @timestamp for each chunk, containing only REQUIRED_FIELDS.
+ list: Log entries sorted by @timestamp for each chunk, containing only FIELDS_TO_EXTRACT.
Raises:
ElasticServerDownError: If Elasticsearch is unreachable.
@@ -72,7 +72,7 @@ def search(self, minutes_back_to_lookup: int) -> Iterator[list]:
search = Search(using=self.elastic_client, index="logstash-*")
q = Q("range", **{"@timestamp": {"gte": chunk_start, "lt": chunk_end}})
search = search.query(q)
- search.source(REQUIRED_FIELDS)
+ search = search.source(FIELDS_TO_EXTRACT)
result = list(search.scan())
self.log.debug(f"found {len(result)} hits")
result.sort(key=lambda hit: hit["@timestamp"])
diff --git a/greedybear/cronjobs/repositories/trending_bucket.py b/greedybear/cronjobs/repositories/trending_bucket.py
new file mode 100644
index 000000000..5b7f9101c
--- /dev/null
+++ b/greedybear/cronjobs/repositories/trending_bucket.py
@@ -0,0 +1,73 @@
+from collections import Counter
+from collections.abc import Iterable
+from datetime import datetime
+
+from django.db import connection
+from django.db.models import Sum
+
+from greedybear.models import AttackerActivityBucket
+
+BucketKey = tuple[str, str, datetime]
+
+
+class TrendingBucketRepository:
+ """Repository for reading and writing aggregated attacker activity buckets."""
+
+ UPSERT_BATCH_SIZE = 10_000
+ _UPSERT_VALUE_PLACEHOLDER = "(%s, %s, %s, %s)"
+
+ @classmethod
+ def _build_upsert_query(cls, quoted_table_name: str, row_count: int) -> str:
+ values_sql = ",".join([cls._UPSERT_VALUE_PLACEHOLDER] * row_count)
+ return f"""
+ INSERT INTO {quoted_table_name} (attacker_ip, feed_type, bucket_start, interaction_count)
+ VALUES {values_sql}
+ ON CONFLICT (attacker_ip, feed_type, bucket_start)
+ DO UPDATE
+ SET interaction_count = {quoted_table_name}.interaction_count + EXCLUDED.interaction_count
+ """
+
+ @staticmethod
+ def _build_upsert_params(batch: list[tuple[BucketKey, int]]) -> list[object]:
+ params: list[object] = []
+ for (attacker_ip, feed_type, bucket_start), interaction_count in batch:
+ params.extend((attacker_ip, feed_type, bucket_start, interaction_count))
+ return params
+
+ @staticmethod
+ def _normalize_feed_types(feed_types: str | Iterable[str]) -> list[str]:
+ if isinstance(feed_types, str):
+ return [feed_types]
+ return list(feed_types)
+
+ def upsert_bucket_counts(self, counters: Counter[BucketKey]) -> int:
+ """Insert or increment bucket counts in batches and return the number of unique keys."""
+ if not counters:
+ return 0
+
+ table_name = AttackerActivityBucket._meta.db_table
+ quoted_table_name = connection.ops.quote_name(table_name)
+ counter_items = list(counters.items())
+ with connection.cursor() as cursor:
+ for batch_start in range(0, len(counter_items), self.UPSERT_BATCH_SIZE):
+ batch = counter_items[batch_start : batch_start + self.UPSERT_BATCH_SIZE]
+ query = self._build_upsert_query(quoted_table_name, len(batch))
+ params = self._build_upsert_params(batch)
+ cursor.execute(query, params)
+
+ return len(counters)
+
+ def get_counts_in_window(self, window_start: datetime, window_end: datetime, feed_types: str | Iterable[str]) -> dict[str, int]:
+ """Return summed interaction counts per attacker IP inside the requested time window."""
+ queryset = AttackerActivityBucket.objects.filter(bucket_start__gte=window_start, bucket_start__lt=window_end)
+ normalized_feed_types = self._normalize_feed_types(feed_types)
+
+ if "all" not in normalized_feed_types:
+ queryset = queryset.filter(feed_type__in=normalized_feed_types)
+
+ return dict(queryset.values("attacker_ip").annotate(total=Sum("interaction_count")).values_list("attacker_ip", "total"))
+
+ def delete_older_than(self, cutoff: datetime) -> int:
+ """Delete buckets older than the cutoff and return Django's reported delete count."""
+ deleted_count, _ = AttackerActivityBucket.objects.filter(bucket_start__lt=cutoff).delete()
+ return deleted_count
diff --git a/greedybear/cronjobs/schedules.py b/greedybear/cronjobs/schedules.py
index 03271a232..eb9615767 100644
--- a/greedybear/cronjobs/schedules.py
+++ b/greedybear/cronjobs/schedules.py
@@ -48,6 +48,12 @@ def setup_schedules():
"func": "greedybear.tasks.monitor_logs",
"cron": "7 * * * *",
},
+ # Trending Buckets Cleanup: Hourly at :12
+ {
+ "name": "clean_up_trending_buckets",
+ "func": "greedybear.tasks.clean_up_trending_buckets",
+ "cron": "12 * * * *",
+ },
# Cluster Commands: Daily at 01:07
{
"name": "cluster_commands",
diff --git a/greedybear/cronjobs/trending.py b/greedybear/cronjobs/trending.py
new file mode 100644
index 000000000..46d91f1a6
--- /dev/null
+++ b/greedybear/cronjobs/trending.py
@@ -0,0 +1,112 @@
+from collections.abc import Mapping
+
+AttackerCounts = Mapping[str, int]
+AttackerRank = int | None
+RankedAttacker = dict[str, str | int | float | None]
+
+UNRANKED_ATTACKER_SORT_ORDER = 10**9
+
+
+def _rank_map(sorted_counts: list[tuple[str, int]]) -> dict[str, int]:
+ return {attacker_ip: rank for rank, (attacker_ip, _) in enumerate(sorted_counts, start=1)}
+
+
+def growth_score(current_count: int, previous_count: int) -> float:
+ if previous_count == 0:
+ return round(float(current_count), 4)
+ return round((current_count - previous_count) / previous_count, 4)
+
+
+def rank_delta(current_rank: int | None, previous_rank: int | None) -> int | None:
+ if current_rank is not None and previous_rank is not None:
+ return previous_rank - current_rank
+ if current_rank is None and previous_rank is not None:
+ return -previous_rank
+ return None
+
+
+def attacker_sort_tuple(
+ attacker_ip: str,
+ current_rank: AttackerRank,
+ current_count: int,
+ previous_count: int,
+) -> tuple[bool, int, int, int, str]:
+ return (
+ current_rank is None,
+ current_rank or UNRANKED_ATTACKER_SORT_ORDER,
+ -(current_count - previous_count),
+ -previous_count,
+ attacker_ip,
+ )
+
+
+def _ranked_attacker(
+ attacker_ip: str,
+ current_rank: AttackerRank,
+ previous_rank: AttackerRank,
+ current_count: int,
+ previous_count: int,
+) -> RankedAttacker:
+ return {
+ "attacker_ip": attacker_ip,
+ "current_interactions": current_count,
+ "previous_interactions": previous_count,
+ "interaction_delta": current_count - previous_count,
+ "growth_score": growth_score(current_count, previous_count),
+ "current_rank": current_rank,
+ "previous_rank": previous_rank,
+ "rank_delta": rank_delta(current_rank, previous_rank),
+ }
+
+
+def build_ranked_attackers(current_counts: AttackerCounts, previous_counts: AttackerCounts, limit: int) -> list[RankedAttacker]:
+ sorted_current = sorted(current_counts.items(), key=lambda item: (-item[1], item[0]))
+ sorted_previous = sorted(previous_counts.items(), key=lambda item: (-item[1], item[0]))
+
+ current_ranks = _rank_map(sorted_current)
+ previous_ranks = _rank_map(sorted_previous)
+
+ candidate_ips = {ip for ip, _ in sorted_current[:limit]}
+ candidate_ips |= {ip for ip, _ in sorted_previous[:limit]}
+
+ previous_rank_offset = 1
+
+ def _effective_rank(attacker_ip: str) -> int:
+ current_rank = current_ranks.get(attacker_ip)
+ if current_rank is not None:
+ return current_rank
+
+ previous_rank = previous_ranks.get(attacker_ip)
+ if previous_rank is not None:
+ return previous_rank + previous_rank_offset
+
+ return UNRANKED_ATTACKER_SORT_ORDER
+
+ sorted_ips = sorted(
+ candidate_ips,
+ key=lambda attacker_ip: (
+ _effective_rank(attacker_ip),
+ -(current_counts.get(attacker_ip, 0) - previous_counts.get(attacker_ip, 0)),
+ -previous_counts.get(attacker_ip, 0),
+ attacker_ip,
+ ),
+ )[:limit]
+
+ return [
+ _ranked_attacker(
+ attacker_ip,
+ current_ranks.get(attacker_ip),
+ previous_ranks.get(attacker_ip),
+ current_counts.get(attacker_ip, 0),
+ previous_counts.get(attacker_ip, 0),
+ )
+ for attacker_ip in sorted_ips
+ ]
+
+
+def validate_window_minutes(window_minutes: int, max_window_minutes: int) -> int:
+ if window_minutes > max_window_minutes:
+ raise ValueError(f"window_minutes cannot be greater than {max_window_minutes}")
+ if window_minutes % 60 != 0:
+ raise ValueError("window_minutes must be a multiple of 60")
+ return window_minutes
diff --git a/greedybear/migrations/0050_attackeractivitybucket.py b/greedybear/migrations/0050_attackeractivitybucket.py
new file mode 100644
index 000000000..2ffe55f9f
--- /dev/null
+++ b/greedybear/migrations/0050_attackeractivitybucket.py
@@ -0,0 +1,32 @@
+# Generated by Django 5.2.12 on 2026-03-20 12:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("greedybear", "0049_rename_generalhoneypot_honeypot_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AttackerActivityBucket",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("attacker_ip", models.GenericIPAddressField()),
+ ("feed_type", models.CharField(max_length=32)),
+ ("bucket_start", models.DateTimeField()),
+ ("interaction_count", models.IntegerField(default=0)),
+ ],
+ options={
+ "indexes": [
+ models.Index(fields=["bucket_start"], name="greedybear__bucket__ce4aaf_idx"),
+ models.Index(fields=["feed_type", "bucket_start"], name="greedybear__feed_ty_84e90b_idx"),
+ models.Index(fields=["attacker_ip", "bucket_start"], name="greedybear__attacke_910f2f_idx"),
+ ],
+ "constraints": [
+ models.UniqueConstraint(fields=("attacker_ip", "feed_type", "bucket_start"), name="unique_attacker_activity_bucket"),
+ ],
+ },
+ ),
+ ]
diff --git a/greedybear/models.py b/greedybear/models.py
index 4156634c9..be153518e 100644
--- a/greedybear/models.py
+++ b/greedybear/models.py
@@ -305,3 +305,23 @@ class ShareToken(models.Model):
def __str__(self):
status = "revoked" if self.revoked else "active"
return f"ShareToken({self.token_hash[:12]}… [{status}])"
+
+
+class AttackerActivityBucket(models.Model):
+ attacker_ip = models.GenericIPAddressField()
+ feed_type = models.CharField(max_length=32)
+ bucket_start = models.DateTimeField()
+ interaction_count = models.IntegerField(default=0)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=["attacker_ip", "feed_type", "bucket_start"], name="unique_attacker_activity_bucket"),
+ ]
+ indexes = [
+ models.Index(fields=["bucket_start"]),
+ models.Index(fields=["feed_type", "bucket_start"]),
+ models.Index(fields=["attacker_ip", "bucket_start"]),
+ ]
+
+ def __str__(self):
+ return f"{self.attacker_ip} [{self.feed_type}] @ {self.bucket_start} ({self.interaction_count})"
diff --git a/greedybear/settings.py b/greedybear/settings.py
index e1d9b60f2..c364a4b4c 100644
--- a/greedybear/settings.py
+++ b/greedybear/settings.py
@@ -59,6 +59,27 @@
CSRF_COOKIE_SAMESITE = "Strict"
CSRF_COOKIE_HTTPONLY = True
+# Prevent browsers from MIME-sniffing the content type, reducing
+# the risk of drive-by downloads.
+SECURE_CONTENT_TYPE_NOSNIFF = True
+
+# Block framing of the site entirely to prevent clickjacking.
+# XFrameOptionsMiddleware is already in the middleware stack but
+# defaults to SAMEORIGIN; DENY is stricter and appropriate here
+# because GreedyBear has no legitimate need to be framed.
+X_FRAME_OPTIONS = "DENY"
+
+if STAGE_PRODUCTION:
+ # Mark session and CSRF cookies as Secure so they are only
+ # sent over HTTPS connections. Gated to production because
+ # local/CI environments run plain HTTP.
+ SESSION_COOKIE_SECURE = True
+ CSRF_COOKIE_SECURE = True
+ # NOTE: SECURE_SSL_REDIRECT is intentionally omitted. TLS is
+ # terminated at nginx, which already redirects HTTP -> HTTPS.
+ # Enabling it here would cause an infinite redirect loop because
+ # Django only sees plain HTTP from the gunicorn unix socket.
+
# Read DJANGO_ALLOWED_HOSTS from env (comma-separated).
# Falls back to ["*"] for backward compatibility.
# For security checks, see greedybear/checks.py.
@@ -69,7 +90,7 @@
ALLOWED_HOSTS = ["*"]
# certego_saas
-HOST_URI = "http://localhost"
+HOST_URI = os.environ.get("HOST_URI", "http://localhost")
HOST_NAME = "GreedyBear"
# Application definition
@@ -465,6 +486,9 @@
COWRIE_SESSION_RETENTION = int(os.environ.get("COWRIE_SESSION_RETENTION", "365"))
COMMAND_SEQUENCE_RETENTION = int(os.environ.get("COMMAND_SEQUENCE_RETENTION", "365"))
+TRENDING_MAX_WINDOW_MINUTES = int(os.environ.get("TRENDING_MAX_WINDOW_MINUTES", str((24 * 31 * 60) // 2)))
+TRENDING_BUCKET_RETENTION_HOURS = int(os.environ.get("TRENDING_BUCKET_RETENTION_HOURS", str(24 * 31)))
+
THREATFOX_API_KEY = os.environ.get("THREATFOX_API_KEY", "")
ABUSEIPDB_API_KEY = os.environ.get("ABUSEIPDB_API_KEY", "")
diff --git a/greedybear/tasks.py b/greedybear/tasks.py
index 0a26ffc02..8bf906631 100644
--- a/greedybear/tasks.py
+++ b/greedybear/tasks.py
@@ -105,3 +105,9 @@ def extract_spamhaus_drop():
from greedybear.cronjobs.spamhaus_drop import SpamhausDropCron
SpamhausDropCron().execute()
+
+
+def clean_up_trending_buckets():
+ from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron
+
+ TrendingBucketCleanupCron().execute()
diff --git a/greedybear/utils.py b/greedybear/utils.py
index aeb5f310a..b87056ae5 100644
--- a/greedybear/utils.py
+++ b/greedybear/utils.py
@@ -27,6 +27,19 @@ def is_ip_address(string: str) -> bool:
return True
+def is_non_global_ip(value) -> bool:
+ """
+ Return True when an IP should be treated as non-global/unroutable.
+
+ Args:
+ value: IPv4Address or IPv6Address from ipaddress module.
+
+ Returns:
+ bool: True for loopback/private/multicast/link-local/reserved addresses.
+ """
+ return value.is_loopback or value.is_private or value.is_multicast or value.is_link_local or value.is_reserved
+
+
def is_valid_domain(string: str) -> bool:
"""
Validate if a string is a safe domain name for use in STIX patterns.
diff --git a/pyproject.toml b/pyproject.toml
index 29713ba01..33cddbe1e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,47 +1,60 @@
[project]
name = "greedybear"
-version = "3.3.2"
-requires-python = "==3.13.*"
+version = "3.4.0"
+description = "Threat intelligence platform that extracts attack data from a T-Pot or a cluster of them and generates actionable live feeds."
+readme = "README.md"
+license = "MIT"
+license-files = ["LICENSE"]
+requires-python = ">=3.13,<3.14"
dependencies = [
# Django core
- "Django==5.2.13",
- "djangorestframework==3.17.1",
+ "Django~=5.2.13",
+ "djangorestframework~=3.17.1",
"django-rest-email-auth==5.0.0",
- "django-ses==4.7.2",
- "django-q2==1.9.0",
- "croniter==6.2.2",
+ "django-ses~=4.7",
+ "django-q2~=1.9",
+ "croniter~=6.2",
"certego-saas==0.7.12",
# Server Gateway Interface
- "gunicorn==25.3.0",
+ "gunicorn~=25.3",
# Data stores
"elasticsearch==9.3.0",
- "psycopg[c]==3.3.3",
+ "psycopg[c]~=3.3",
# ML / data science
- "scikit-learn==1.8.0",
- "pandas==3.0.2",
- "numpy==2.4.4",
- "joblib==1.5.3",
- "datasketch==1.9.0",
+ "scikit-learn~=1.8.0",
+ "pandas~=3.0",
+ "numpy~=2.4",
+ "joblib~=1.5",
+ "datasketch~=1.10",
# File Format Support
- "feedparser==6.0.12",
- "stix2==3.0.2",
+ "feedparser~=6.0",
+ "stix2~=3.0",
# Utilities
- "requests==2.33.1",
- "slack-sdk==3.41.0",
+ "requests~=2.33",
+ "slack-sdk~=3.41",
]
+[project.urls]
+Repository = "https://github.com/GreedyBear-Project/GreedyBear"
+Documentation = "https://github.com/GreedyBear-Project/GreedyBear/wiki"
+Blog = "https://greedybear-project.github.io/"
+
[dependency-groups]
dev = [
+ "django-watchfiles~=1.4",
+]
+test = [
"coverage==7.13.5",
"django-test-migrations==1.5.0",
- "django-watchfiles==1.4.0",
]
lint = [
- "ruff==0.15.10",
+ "ruff==0.15.11",
]
[tool.uv]
+# necessary because certego-saas transitively depends on djangorestframework-filters 1.0.0.dev2 (dev prerelease)
prerelease = "allow"
+# necessary because certego-saas pins Markdown <3.4, but we want a current release for security fixes
override-dependencies = ["markdown==3.10.2"]
[tool.ruff]
@@ -49,6 +62,8 @@ extend-exclude = [
".github",
".idea",
".vscode",
+ ".venv",
+ "venv",
"**/migrations/*",
]
include = ["*.py"]
@@ -68,17 +83,30 @@ skip-magic-trailing-comma = false
[tool.ruff.lint]
select = [
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "DJ", # flake8-django
"E", # pycodestyle errors
- "W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
+ "ICN", # flake8-import-conventions
+ "LOG", # flake8-logging
"N", # pep8-naming
+ "TC", # flake8-type-checking
"UP", # pyupgrade
- "B", # flake8-bugbear
- "C4", # flake8-comprehensions
- "DJ", # flake8-django
+ "W", # pycodestyle warnings
]
-ignore = [
- "F403", # Allow wildcard imports in __init__.py files
- "E501", # Allow long lines in docstrings
+
+[tool.ruff.lint.per-file-ignores]
+# Re-exporting submodules with wildcard imports is intentional in package __init__.py files
+"**/__init__.py" = ["F403"]
+# Long parameter documentation lines in API view docstrings
+"api/views/*" = ["E501"]
+
+[tool.ruff.lint.isort]
+known-first-party = [
+ "api",
+ "authentication",
+ "configuration",
+ "greedybear",
]
diff --git a/static/gsoc_logo.png b/static/gsoc_logo.png
new file mode 100644
index 000000000..9c5ca29fb
Binary files /dev/null and b/static/gsoc_logo.png differ
diff --git a/tests/__init__.py b/tests/__init__.py
index 021340269..d8e3d58ef 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -53,6 +53,7 @@ def setUpTestData(cls):
recurrence_probability=0.1,
expected_interactions=11.1,
attacker_country="China",
+ attacker_country_code="CN",
)
cls.ioc_2 = IOC.objects.create(
@@ -74,6 +75,7 @@ def setUpTestData(cls):
recurrence_probability=0.1,
expected_interactions=11.1,
attacker_country="China",
+ attacker_country_code="CN",
)
cls.ioc_3 = IOC.objects.create(
@@ -95,6 +97,7 @@ def setUpTestData(cls):
recurrence_probability=0.1,
expected_interactions=11.1,
attacker_country="United States",
+ attacker_country_code="US",
)
cls.ioc_domain = IOC.objects.create(
@@ -144,6 +147,7 @@ def setUpTestData(cls):
attack_count=1,
interaction_count=1,
attacker_country="Russia",
+ attacker_country_code="RU",
)
cls.ioc_inactive_country.honeypots.add(cls.ddospot)
cls.ioc_inactive_country.save()
diff --git a/tests/api/views/test_cowrie_session_view.py b/tests/api/views/test_cowrie_session_view.py
index 5d08a6ad4..fa960d5bb 100644
--- a/tests/api/views/test_cowrie_session_view.py
+++ b/tests/api/views/test_cowrie_session_view.py
@@ -1,6 +1,7 @@
from django.test import override_settings
from rest_framework.test import APIClient
+from greedybear.models import CowrieSession
from tests import CustomTestCase
@@ -34,6 +35,27 @@ def test_ip_address_query_with_similar(self):
self.assertNotIn("sessions", response.data)
self.assertEqual(len(response.data["sources"]), 2)
+ def test_include_similar_excludes_non_positive_duration_sessions(self):
+ """Test that include_similar only returns sessions with duration > 0."""
+ CowrieSession.objects.create(
+ session_id=int("dddddddddddd", 16),
+ start_time=self.current_time,
+ duration=0,
+ login_attempt=True,
+ command_execution=True,
+ interaction_count=1,
+ source=self.ioc_3,
+ commands=self.command_sequence_2,
+ )
+
+ response = self.client.get("/api/cowrie_session?query=140.246.171.141&include_similar=true&include_session_data=true")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("99.99.99.99", response.data["sources"])
+ self.assertNotIn("100.100.100.100", response.data["sources"])
+ self.assertTrue(any(session["duration"] > 0 for session in response.data["sessions"]))
+ self.assertFalse(any(session["source"] == "100.100.100.100" for session in response.data["sessions"]))
+
def test_ip_address_query_with_credentials(self):
"""Test view with a valid IP address query including credentials."""
response = self.client.get("/api/cowrie_session?query=140.246.171.141&include_credentials=true")
diff --git a/tests/api/views/test_enrichment_view.py b/tests/api/views/test_enrichment_view.py
index e7ad06f4d..21772f8e0 100644
--- a/tests/api/views/test_enrichment_view.py
+++ b/tests/api/views/test_enrichment_view.py
@@ -1,5 +1,6 @@
from rest_framework.test import APIClient
+from greedybear.models import Sensor
from tests import CustomTestCase
@@ -86,3 +87,16 @@ def test_valid_domain(self):
response = self.client.get("/api/enrichment?query=example.com")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["found"], False)
+
+ def test_enrichment_includes_sensors(self):
+ """Sensors field appears in enrichment response for authenticated users."""
+
+ sensor = Sensor.objects.create(address="10.0.0.3", label="enrichment-sensor")
+ self.ioc.sensors.add(sensor)
+ response = self.client.get(f"/api/enrichment?query={self.ioc.name}")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["found"], True)
+ sensors = response.json()["ioc"]["sensors"]
+ self.assertEqual(len(sensors), 1)
+ self.assertEqual(sensors[0]["address"], "10.0.0.3")
+ self.assertEqual(sensors[0]["label"], "enrichment-sensor")
diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py
index 2b70df106..daa303056 100644
--- a/tests/api/views/test_feeds_advanced_view.py
+++ b/tests/api/views/test_feeds_advanced_view.py
@@ -8,7 +8,7 @@
from rest_framework.test import APIClient
from api.throttles import SharedFeedRateThrottle
-from greedybear.models import IOC, AutonomousSystem, IocType, ShareToken
+from greedybear.models import IOC, AutonomousSystem, IocType, Sensor, ShareToken
from tests import CustomTestCase
@@ -109,15 +109,37 @@ def test_200_feed_contains_attacker_country_code(self):
"""
self.ioc.attacker_country_code = "NP"
self.ioc.save()
-
response = self.client.get("/api/feeds/advanced/")
-
iocs = response.json()["iocs"]
target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None)
-
self.assertIsNotNone(target_ioc)
self.assertEqual(target_ioc["attacker_country_code"], "NP")
+ def test_feeds_advanced_includes_sensors(self):
+ """Sensors field appears in feeds_advanced response for authenticated users."""
+ sensor = Sensor.objects.create(address="10.0.0.1", label="test-sensor")
+ self.ioc.sensors.add(sensor)
+ response = self.client.get("/api/feeds/advanced/")
+ self.assertEqual(response.status_code, 200)
+ iocs = response.json()["iocs"]
+ target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None)
+ self.assertIsNotNone(target_ioc)
+ self.assertIn("sensors", target_ioc)
+ self.assertEqual(len(target_ioc["sensors"]), 1)
+ self.assertEqual(target_ioc["sensors"][0]["address"], "10.0.0.1")
+ self.assertEqual(target_ioc["sensors"][0]["label"], "test-sensor")
+
+ def test_public_feeds_excludes_sensors(self):
+ """Sensors field must NOT appear in public feeds response."""
+ sensor = Sensor.objects.create(address="10.0.0.2", label="secret-sensor")
+ self.ioc.sensors.add(sensor)
+ self.client.logout()
+ response = self.client.get("/api/feeds/cowrie/all/recent.json")
+ self.assertEqual(response.status_code, 200)
+ iocs = response.json()["iocs"]
+ for ioc in iocs:
+ self.assertNotIn("sensors", ioc)
+
class FeedsEnhancementsTestCase(CustomTestCase):
"""Tests for advanced filtering, STIX export, and shareable feeds functionality."""
@@ -236,12 +258,12 @@ def test_filter_by_date_range(self):
def test_filter_by_country_code(self):
"""Filter by country_code returns only matching IOCs."""
- self.ioc.attacker_country_code = "CN"
+ self.ioc.attacker_country_code = "IT"
self.ioc.save()
- self.ioc2.attacker_country_code = "US"
+ self.ioc2.attacker_country_code = "FR"
self.ioc2.save()
- response = self.client.get("/api/feeds/advanced/?country_code=CN")
+ response = self.client.get("/api/feeds/advanced/?country_code=IT")
self.assertEqual(response.status_code, 200)
iocs = response.json()["iocs"]
self.assertEqual(len(iocs), 1)
diff --git a/tests/api/views/test_feeds_share_view.py b/tests/api/views/test_feeds_share_view.py
new file mode 100644
index 000000000..73009eee9
--- /dev/null
+++ b/tests/api/views/test_feeds_share_view.py
@@ -0,0 +1,157 @@
+from rest_framework.test import APIClient
+
+from greedybear.models import ShareToken
+from tests import CustomTestCase
+
+
+class FeedsShareReasonTestCase(CustomTestCase):
+ """Tests for the optional ?reason= parameter on GET /api/feeds/share."""
+
+ def setUp(self):
+ super().setUp()
+ self.client = APIClient()
+ self.client.force_authenticate(user=self.superuser)
+
+ def test_share_without_reason(self):
+ """When no reason is supplied, ShareToken.reason defaults to empty string."""
+ response = self.client.get("/api/feeds/share")
+ self.assertEqual(response.status_code, 200)
+
+ token_hash = self._hash_from_url(response.json()["url"])
+ share_token = ShareToken.objects.get(token_hash=token_hash)
+ self.assertEqual(share_token.reason, "")
+
+ def test_share_with_reason(self):
+ """When ?reason=... is supplied, it is persisted on the ShareToken."""
+ response = self.client.get("/api/feeds/share?reason=monthly+report")
+ self.assertEqual(response.status_code, 200)
+
+ token_hash = self._hash_from_url(response.json()["url"])
+ share_token = ShareToken.objects.get(token_hash=token_hash)
+ self.assertEqual(share_token.reason, "monthly report")
+
+ def test_share_reason_truncated_at_256_chars(self):
+ """A reason longer than 256 characters is silently truncated."""
+ long_reason = "x" * 300
+ response = self.client.get(f"/api/feeds/share?reason={long_reason}")
+ self.assertEqual(response.status_code, 200)
+
+ token_hash = self._hash_from_url(response.json()["url"])
+ share_token = ShareToken.objects.get(token_hash=token_hash)
+ self.assertEqual(len(share_token.reason), 256)
+
+ def test_share_reason_only_set_on_create(self):
+ """get_or_create: when the same token already exists, reason is NOT overwritten."""
+ r1 = self.client.get("/api/feeds/share?reason=first")
+ self.assertEqual(r1.status_code, 200)
+
+ # Same feed params → same signed token → get_or_create returns existing record
+ r2 = self.client.get("/api/feeds/share?reason=second")
+ self.assertEqual(r2.status_code, 200)
+
+ # Both calls must have produced the exact same token/URL
+ self.assertEqual(r1.json()["url"], r2.json()["url"])
+
+ # Only one ShareToken should exist (not two)
+ token_hash = self._hash_from_url(r1.json()["url"])
+ self.assertEqual(ShareToken.objects.filter(token_hash=token_hash).count(), 1)
+
+ share_token = ShareToken.objects.get(token_hash=token_hash)
+ self.assertEqual(share_token.reason, "first")
+
+ # ── helpers ────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def _hash_from_url(url):
+ import hashlib
+
+ raw_token = url.split("/api/feeds/consume/")[1]
+ return hashlib.sha256(raw_token.encode()).hexdigest()
+
+
+class FeedsTokensListTestCase(CustomTestCase):
+ """Tests for GET /api/feeds/tokens/ — lists the calling user's share tokens."""
+
+ def setUp(self):
+ super().setUp()
+ self.client = APIClient()
+ self.client.force_authenticate(user=self.superuser)
+
+ def test_empty_list(self):
+ """A user with no tokens gets an empty JSON list."""
+ response = self.client.get("/api/feeds/tokens/")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [])
+
+ def test_list_returns_own_tokens(self):
+ """After creating two tokens, the list endpoint returns both."""
+ self.client.get("/api/feeds/share?reason=alpha")
+ self.client.get("/api/feeds/share?reason=beta&asn=11111")
+ response = self.client.get("/api/feeds/tokens/")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(len(data), 2)
+
+ reasons = {t["reason"] for t in data}
+ self.assertEqual(reasons, {"alpha", "beta"})
+
+ def test_list_returns_only_own_tokens(self):
+ """Tokens created by another user are NOT visible."""
+ self.client.get("/api/feeds/share?reason=superuser-token")
+
+ other_client = APIClient()
+ other_client.force_authenticate(user=self.regular_user)
+ other_client.get("/api/feeds/share?reason=regular-token&asn=22222")
+
+ # superuser sees only their own
+ response = self.client.get("/api/feeds/tokens/")
+ self.assertEqual(len(response.json()), 1)
+ self.assertEqual(response.json()[0]["reason"], "superuser-token")
+
+ # regular_user sees only their own
+ response = other_client.get("/api/feeds/tokens/")
+ self.assertEqual(len(response.json()), 1)
+ self.assertEqual(response.json()[0]["reason"], "regular-token")
+
+ def test_hash_prefix_not_full_hash(self):
+ """The response exposes only the first 12 chars of the hash."""
+ self.client.get("/api/feeds/share?reason=test")
+ response = self.client.get("/api/feeds/tokens/")
+ data = response.json()
+ self.assertEqual(len(data[0]["hash_prefix"]), 12)
+
+ def test_token_metadata_fields(self):
+ """Each token entry contains the expected metadata keys."""
+ self.client.get("/api/feeds/share?reason=check-fields")
+ response = self.client.get("/api/feeds/tokens/")
+ token = response.json()[0]
+ expected_keys = {"hash_prefix", "reason", "created_at", "revoked", "revoked_at"}
+ self.assertEqual(set(token.keys()), expected_keys)
+ self.assertFalse(token["revoked"])
+ self.assertIsNone(token["revoked_at"])
+
+ def test_revoked_token_shows_status(self):
+ """After revocation, the token list reflects revoked=True."""
+ share = self.client.get("/api/feeds/share?reason=to-revoke")
+ raw_token = share.json()["url"].split("/api/feeds/consume/")[1]
+ self.client.get(f"/api/feeds/revoke/{raw_token}")
+
+ response = self.client.get("/api/feeds/tokens/")
+ token = response.json()[0]
+ self.assertTrue(token["revoked"])
+ self.assertIsNotNone(token["revoked_at"])
+
+ def test_unauthenticated_returns_401_or_403(self):
+ """Unauthenticated requests are rejected."""
+ anon = APIClient()
+ response = anon.get("/api/feeds/tokens/")
+ self.assertIn(response.status_code, [401, 403])
+
+ def test_list_ordering_newest_first(self):
+ """Tokens are returned newest-first (descending created_at)."""
+ self.client.get("/api/feeds/share?reason=first")
+ self.client.get("/api/feeds/share?reason=second&asn=22222")
+ response = self.client.get("/api/feeds/tokens/")
+ data = response.json()
+ self.assertEqual(data[0]["reason"], "second")
+ self.assertEqual(data[1]["reason"], "first")
diff --git a/tests/api/views/test_feeds_throttle.py b/tests/api/views/test_feeds_throttle.py
deleted file mode 100644
index c69e9c535..000000000
--- a/tests/api/views/test_feeds_throttle.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from unittest.mock import patch
-
-from django.core.cache import cache
-from rest_framework import status
-from rest_framework.test import APIClient
-
-from tests import CustomTestCase
-
-
-class FeedsThrottleTestCase(CustomTestCase):
- """Tests that rate limiting is applied to feeds endpoints."""
-
- def setUp(self):
- super().setUp()
- cache.clear()
- self.client = APIClient()
- self.client.force_authenticate(user=self.superuser)
-
- @patch("api.throttles.FeedsAdvancedThrottle.get_rate", return_value="1/minute")
- def test_feeds_advanced_throttled(self, mock_rate):
- """Verify feeds_advanced returns 429 after exceeding the rate limit."""
- response = self.client.get("/api/feeds/advanced/")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- response = self.client.get("/api/feeds/advanced/")
- self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
-
- @patch("api.throttles.FeedsAdvancedThrottle.get_rate", return_value="1/minute")
- def test_feeds_asn_throttled(self, mock_rate):
- """Verify feeds_asn returns 429 after exceeding the rate limit."""
- response = self.client.get("/api/feeds/asn/")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- response = self.client.get("/api/feeds/asn/")
- self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
-
- @patch("api.throttles.FeedsThrottle.get_rate", return_value="1/minute")
- def test_feeds_pagination_throttled(self, mock_rate):
- """Verify feeds_pagination returns 429 after exceeding the rate limit."""
- response = self.client.get("/api/feeds/")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- response = self.client.get("/api/feeds/")
- self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
-
- @patch("api.throttles.FeedsThrottle.get_rate", return_value="1/minute")
- def test_feeds_legacy_throttled(self, mock_rate):
- """Verify legacy feeds endpoint returns 429 after exceeding the rate limit."""
- url = "/api/feeds/all/all/recent.json"
- response = self.client.get(url)
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- response = self.client.get(url)
- self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
-
- def test_feeds_advanced_within_limit(self):
- """Verify feeds_advanced succeeds when within the rate limit."""
- response = self.client.get("/api/feeds/advanced/")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- def test_feeds_asn_within_limit(self):
- """Verify feeds_asn succeeds when within the rate limit."""
- response = self.client.get("/api/feeds/asn/")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- def test_feeds_unauthenticated_access(self):
- """Verify public feeds endpoints are accessible without authentication."""
- client = APIClient()
- response = client.get("/api/feeds/all/all/recent.json")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- def test_feeds_pagination_unauthenticated_access(self):
- """Verify public feeds pagination endpoint is accessible without authentication."""
- client = APIClient()
- response = client.get("/api/feeds/")
- self.assertEqual(response.status_code, status.HTTP_200_OK)
-
- def test_feeds_advanced_unauthenticated_rejected(self):
- """Verify authenticated feeds_advanced endpoint rejects unauthenticated requests."""
- client = APIClient()
- response = client.get("/api/feeds/advanced/")
- self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
- def test_feeds_asn_unauthenticated_rejected(self):
- """Verify authenticated feeds_asn endpoint rejects unauthenticated requests."""
- client = APIClient()
- response = client.get("/api/feeds/asn/")
- self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
diff --git a/tests/api/views/test_news_view.py b/tests/api/views/test_news_view.py
index 3cb78ca19..725fb9845 100644
--- a/tests/api/views/test_news_view.py
+++ b/tests/api/views/test_news_view.py
@@ -32,38 +32,6 @@ def test_returns_cached_data(self, mock_parse):
self.assertEqual(result, cached_data)
mock_parse.assert_not_called()
- @patch("api.views.utils.feedparser.parse")
- def test_filters_only_greedybear_posts(self, mock_parse):
- mock_parse.return_value = FeedParserDict(
- entries=[
- FeedParserDict(
- title="IntelOwl Update",
- summary="intelowl news",
- published="Wed, 01 Jan 2026 00:00:00 GMT",
- published_parsed=(2026, 1, 1, 0, 0, 0, 2, 1, 0),
- link="https://example.com/1",
- ),
- FeedParserDict(
- title="GreedyBear v3 Release",
- summary="greedybear release notes",
- published="Thu, 29 Jan 2026 00:00:00 GMT",
- published_parsed=(2026, 1, 29, 0, 0, 0, 3, 29, 0),
- link="https://example.com/2",
- ),
- FeedParserDict(
- title="IntelOwl Improvements",
- summary="Not related to GreedyBear",
- published="Mon, 01 Sep 2025 00:00:00 GMT",
- published_parsed=(2025, 9, 1, 0, 0, 0, 0, 244, 0),
- link="https://example.com/3",
- ),
- ]
- )
-
- result = get_greedybear_news()
- self.assertEqual(len(result), 1)
- self.assertEqual(result[0]["title"], "GreedyBear v3 Release")
-
@patch("api.views.utils.feedparser.parse")
def test_sorts_posts_by_date_desc(self, mock_parse):
mock_parse.return_value = FeedParserDict(
diff --git a/tests/api/views/test_statistics_view.py b/tests/api/views/test_statistics_view.py
index 59a7b4dfa..fb951559a 100644
--- a/tests/api/views/test_statistics_view.py
+++ b/tests/api/views/test_statistics_view.py
@@ -65,6 +65,12 @@ def test_200_countries(self):
self.assertNotIn("Russia", countries)
self.assertEqual(counts["China"], 2)
self.assertEqual(counts["United States"], 1)
+
+ # check codes
+ codes = {item["country"]: item["code"] for item in data}
+ self.assertEqual(codes["China"], "CN")
+ self.assertEqual(codes["United States"], "US")
+
# Results must be ordered descending by count
count_values = [item["count"] for item in data]
self.assertEqual(count_values, sorted(count_values, reverse=True))
diff --git a/tests/authentication/test_auth.py b/tests/authentication/test_auth.py
index 721e13143..0367fee1d 100644
--- a/tests/authentication/test_auth.py
+++ b/tests/authentication/test_auth.py
@@ -61,6 +61,14 @@ def test_login_200(self):
self.assertEqual(AuthToken.objects.count(), 1)
+ def test_login_does_not_set_current_site_cache(self):
+ """Login must not store the Host header in global cache."""
+ cache.clear()
+ body = {**self.creds}
+ response = self.client.post(login_uri, body)
+ self.assertEqual(response.status_code, 200)
+ self.assertIsNone(cache.get("current_site"))
+
def test_logout_204(self):
self.assertEqual(AuthToken.objects.count(), 0)
diff --git a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py
index b9dcdbecd..a0561d542 100644
--- a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py
+++ b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py
@@ -60,6 +60,134 @@ def test_partial_strategy_success(self, mock_factory, mock_scores):
# Scoring should be called with successful IOCs
mock_scores.return_value.score_only.assert_called_once()
+ @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater")
+ @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores")
+ @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory")
+ def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_factory, mock_scores, mock_bucket_updater_cls):
+ pipeline = self._create_pipeline_with_real_factory()
+ pipeline.log = MagicMock()
+
+ hits = [
+ MockElasticHit({"src_ip": "2.2.2.2", "type": "SuccessHoneypot"}),
+ ]
+ pipeline.elastic_repo.search.return_value = [hits]
+ pipeline.ioc_repo.is_empty.return_value = False
+ pipeline.ioc_repo.is_ready_for_extraction.return_value = True
+
+ bucket_updater = mock_bucket_updater_cls.return_value
+ bucket_updater.total_update_count = 0
+
+ mock_success = MagicMock()
+ mock_success.ioc_records = [self._create_mock_ioc("2.2.2.2")]
+ mock_factory.return_value.get_strategy.return_value = mock_success
+
+ result = pipeline.execute()
+
+ self.assertEqual(result, 1)
+ mock_success.extract_from_hits.assert_called_once()
+ mock_scores.return_value.score_only.assert_called_once()
+ bucket_updater.collect_hits.assert_called_once()
+ bucket_updater.update.assert_called_once()
+
+ @patch("greedybear.cronjobs.extraction.pipeline.caches")
+ @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater")
+ @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores")
+ @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory")
+ def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches):
+ pipeline = self._create_pipeline_with_real_factory()
+ pipeline.log = MagicMock()
+
+ hits = [
+ MockElasticHit({"src_ip": "2.2.2.2", "type": "SuccessHoneypot"}),
+ ]
+ pipeline.elastic_repo.search.return_value = [hits]
+ pipeline.ioc_repo.is_empty.return_value = False
+ pipeline.ioc_repo.is_ready_for_extraction.return_value = True
+
+ bucket_updater = mock_bucket_updater_cls.return_value
+ bucket_updater.total_update_count = 2
+
+ mock_strategy = MagicMock()
+ mock_strategy.ioc_records = []
+ mock_factory.return_value.get_strategy.return_value = mock_strategy
+
+ shared_cache = MagicMock()
+ mock_caches.__getitem__.return_value = shared_cache
+
+ result = pipeline.execute()
+
+ self.assertEqual(result, 0)
+ bucket_updater.collect_hits.assert_called_once()
+ bucket_updater.update.assert_called_once()
+ shared_cache.incr.assert_called_once_with("trending_feeds_version")
+ mock_scores.return_value.score_only.assert_not_called()
+
+ @patch("greedybear.cronjobs.extraction.pipeline.caches")
+ @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater")
+ @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores")
+ @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory")
+ def test_disabled_honeypot_hits_do_not_update_activity_buckets(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches):
+ """Hits from honeypots not ready for extraction must be excluded from bucket updates."""
+ pipeline = self._create_pipeline_with_real_factory()
+ pipeline.log = MagicMock()
+
+ hits = [
+ MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledHoneypot"}),
+ MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledHoneypot"}),
+ MockElasticHit({"src_ip": "2.2.2.2", "type": "EnabledHoneypot"}),
+ ]
+ pipeline.elastic_repo.search.return_value = [hits]
+ pipeline.ioc_repo.is_empty.return_value = False
+ pipeline.ioc_repo.is_ready_for_extraction.side_effect = lambda hp: hp == "EnabledHoneypot"
+
+ bucket_updater = mock_bucket_updater_cls.return_value
+ bucket_updater.total_update_count = 0
+
+ mock_strategy = MagicMock()
+ mock_strategy.ioc_records = []
+ mock_factory.return_value.get_strategy.return_value = mock_strategy
+
+ shared_cache = MagicMock()
+ mock_caches.__getitem__.return_value = shared_cache
+
+ pipeline.execute()
+
+ # collect_hits must be called exactly once, only with hits from the enabled honeypot.
+ bucket_updater.collect_hits.assert_called_once()
+ passed_hits = list(bucket_updater.collect_hits.call_args.args[0])
+ self.assertEqual(len(passed_hits), 1)
+ self.assertEqual(passed_hits[0]["type"], "EnabledHoneypot")
+ self.assertEqual(passed_hits[0]["src_ip"], "2.2.2.2")
+
+ @patch("greedybear.cronjobs.extraction.pipeline.caches")
+ @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater")
+ @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores")
+ @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory")
+ def test_all_honeypots_disabled_skips_bucket_updates_entirely(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches):
+ """When every honeypot in a chunk is disabled, no hits are collected and the trending cache is not invalidated."""
+ pipeline = self._create_pipeline_with_real_factory()
+ pipeline.log = MagicMock()
+
+ hits = [
+ MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledA"}),
+ MockElasticHit({"src_ip": "2.2.2.2", "type": "DisabledB"}),
+ ]
+ pipeline.elastic_repo.search.return_value = [hits]
+ pipeline.ioc_repo.is_empty.return_value = False
+ pipeline.ioc_repo.is_ready_for_extraction.return_value = False
+
+ bucket_updater = mock_bucket_updater_cls.return_value
+ bucket_updater.total_update_count = 0
+
+ shared_cache = MagicMock()
+ mock_caches.__getitem__.return_value = shared_cache
+
+ pipeline.execute()
+
+ bucket_updater.collect_hits.assert_not_called()
+ mock_factory.return_value.get_strategy.assert_not_called()
+ shared_cache.incr.assert_not_called()
+
class TestLargeBatches(E2ETestCase):
"""Tests for large batch processing using REAL strategies."""
diff --git a/tests/greedybear/cronjobs/test_trending.py b/tests/greedybear/cronjobs/test_trending.py
new file mode 100644
index 000000000..0ef047eb1
--- /dev/null
+++ b/tests/greedybear/cronjobs/test_trending.py
@@ -0,0 +1,267 @@
+from datetime import datetime
+from unittest.mock import patch
+
+from django.test import SimpleTestCase, override_settings
+
+from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron
+from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater
+from greedybear.cronjobs.repositories.trending_bucket import TrendingBucketRepository
+from greedybear.cronjobs.trending import (
+ attacker_sort_tuple,
+ build_ranked_attackers,
+ growth_score,
+ rank_delta,
+ validate_window_minutes,
+)
+from greedybear.models import AttackerActivityBucket
+from tests import CustomTestCase
+
+
+class TrendingHelpersTestCase(SimpleTestCase):
+ def test_growth_score(self):
+ self.assertEqual(growth_score(10, 0), 10.0)
+ self.assertEqual(growth_score(12, 8), 0.5)
+ self.assertEqual(growth_score(8, 12), -0.3333)
+
+ def test_rank_delta(self):
+ self.assertEqual(rank_delta(2, 5), 3)
+ self.assertEqual(rank_delta(None, 5), -5)
+ self.assertIsNone(rank_delta(3, None))
+ self.assertIsNone(rank_delta(None, None))
+
+ def test_attacker_sort_tuple_prefers_ranked_entries(self):
+ ranked = attacker_sort_tuple("1.1.1.1", 2, 10, 7)
+ unranked = attacker_sort_tuple("2.2.2.2", None, 10, 7)
+ self.assertLess(ranked, unranked)
+
+ def test_build_ranked_attackers_uses_current_and_previous_candidates(self):
+ current_counts = {"1.1.1.1": 12, "2.2.2.2": 10, "3.3.3.3": 8}
+ previous_counts = {"9.9.9.9": 30, "1.1.1.1": 9}
+
+ ranked = build_ranked_attackers(current_counts, previous_counts, limit=3)
+
+ self.assertEqual(len(ranked), 3)
+ self.assertEqual(ranked[0]["attacker_ip"], "1.1.1.1")
+ returned_ips = {entry["attacker_ip"] for entry in ranked}
+ self.assertIn("9.9.9.9", returned_ips)
+ self.assertEqual(next(entry for entry in ranked if entry["attacker_ip"] == "9.9.9.9")["current_rank"], None)
+
+
+class ValidateWindowMinutesTestCase(SimpleTestCase):
+ def test_validate_window_minutes_returns_valid_value(self):
+ self.assertEqual(validate_window_minutes(120, 240), 120)
+
+ def test_validate_window_minutes_raises_when_above_max(self):
+ with self.assertRaisesMessage(ValueError, "window_minutes cannot be greater than 240"):
+ validate_window_minutes(300, 240)
+
+ def test_validate_window_minutes_raises_when_not_multiple_of_60(self):
+ with self.assertRaisesMessage(ValueError, "window_minutes must be a multiple of 60"):
+ validate_window_minutes(90, 240)
+
+
+class UpdateActivityBucketsFromHitsTestCase(CustomTestCase):
+ def test_upsert_increments_existing_bucket_and_creates_missing_bucket(self):
+ AttackerActivityBucket.objects.create(
+ attacker_ip="1.1.1.1",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=3,
+ )
+
+ bu = BucketUpdater()
+ bu.collect_hits(
+ [
+ {"src_ip": "1.1.1.1", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "1.1.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:50:00"},
+ {"src_ip": "2.2.2.2", "type": "Heralding", "@timestamp": "2026-03-20T09:10:00"},
+ ]
+ )
+ unique_keys = bu.update()
+
+ self.assertEqual(unique_keys, 2)
+
+ existing_bucket = AttackerActivityBucket.objects.get(
+ attacker_ip="1.1.1.1",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ )
+ self.assertEqual(existing_bucket.interaction_count, 5)
+
+ created_bucket = AttackerActivityBucket.objects.get(
+ attacker_ip="2.2.2.2",
+ feed_type="heralding",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ )
+ self.assertEqual(created_bucket.interaction_count, 1)
+
+ def test_invalid_hits_are_ignored(self):
+ bu = BucketUpdater()
+ bu.collect_hits(
+ [
+ {"src_ip": "", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "999.999.999.999", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "3.3.3.3", "type": "", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "3.3.3.3", "type": "cowrie"},
+ ]
+ )
+ unique_keys = bu.update()
+
+ self.assertEqual(unique_keys, 0)
+ self.assertEqual(AttackerActivityBucket.objects.count(), 0)
+
+ def test_invalid_timestamp_is_ignored(self):
+ bu = BucketUpdater()
+ bu.collect_hits(
+ [
+ {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "not-a-timestamp"},
+ ]
+ )
+ unique_keys = bu.update()
+
+ self.assertEqual(unique_keys, 0)
+ self.assertEqual(AttackerActivityBucket.objects.count(), 0)
+
+ def test_non_global_ip_hits_are_ignored(self):
+ bu = BucketUpdater()
+ bu.collect_hits(
+ [
+ {"src_ip": "10.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "127.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "224.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "169.254.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "240.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ ]
+ )
+ unique_keys = bu.update()
+
+ self.assertEqual(unique_keys, 1)
+ self.assertEqual(AttackerActivityBucket.objects.count(), 1)
+ self.assertTrue(AttackerActivityBucket.objects.filter(attacker_ip="8.8.8.8").exists())
+
+ def test_global_ipv6_hit_is_counted(self):
+ bu = BucketUpdater()
+ bu.collect_hits(
+ [
+ {"src_ip": "2001:4860:4860::8888", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"},
+ ]
+ )
+ unique_keys = bu.update()
+
+ self.assertEqual(unique_keys, 1)
+ self.assertTrue(
+ AttackerActivityBucket.objects.filter(
+ attacker_ip="2001:4860:4860::8888",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ ).exists()
+ )
+
+ def test_upsert_uses_multiple_batches_for_large_counter_sets(self):
+ counters = {
+ ("10.0.0.1", "cowrie", datetime(2026, 3, 20, 9, 0)): 1,
+ ("10.0.0.2", "cowrie", datetime(2026, 3, 20, 9, 0)): 1,
+ ("10.0.0.3", "cowrie", datetime(2026, 3, 20, 9, 0)): 1,
+ ("10.0.0.4", "cowrie", datetime(2026, 3, 20, 9, 0)): 1,
+ ("10.0.0.5", "cowrie", datetime(2026, 3, 20, 9, 0)): 1,
+ }
+
+ repository = TrendingBucketRepository()
+ with patch.object(TrendingBucketRepository, "UPSERT_BATCH_SIZE", 2):
+ with patch("greedybear.cronjobs.repositories.trending_bucket.connection.cursor") as mock_cursor_factory:
+ mock_cursor = mock_cursor_factory.return_value.__enter__.return_value
+ inserted = repository.upsert_bucket_counts(counters)
+
+ self.assertEqual(inserted, 5)
+ self.assertEqual(mock_cursor.execute.call_count, 3)
+
+ @patch("greedybear.cronjobs.extraction.bucket_updater.TrendingBucketRepository.upsert_bucket_counts", side_effect=Exception("db down"))
+ def test_upsert_failure_returns_zero(self, mock_upsert):
+ bu = BucketUpdater()
+ bu.collect_hits([{"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}])
+ unique_keys = bu.update()
+ self.assertEqual(unique_keys, 0)
+ mock_upsert.assert_called_once()
+
+
+class TrendingBucketCleanupCronTestCase(CustomTestCase):
+ def setUp(self):
+ super().setUp()
+ self.cron = TrendingBucketCleanupCron()
+
+ @override_settings(
+ TRENDING_BUCKET_RETENTION_HOURS=2,
+ TRENDING_MAX_WINDOW_MINUTES=60,
+ )
+ def test_run_applies_bucket_retention_cleanup(self):
+ AttackerActivityBucket.objects.bulk_create(
+ [
+ AttackerActivityBucket(
+ attacker_ip="2.2.2.2",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 7, 0),
+ interaction_count=1,
+ ),
+ AttackerActivityBucket(
+ attacker_ip="3.3.3.3",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=1,
+ ),
+ ]
+ )
+
+ with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)):
+ self.cron.run()
+
+ self.assertFalse(AttackerActivityBucket.objects.filter(attacker_ip="2.2.2.2").exists())
+ self.assertTrue(AttackerActivityBucket.objects.filter(attacker_ip="3.3.3.3").exists())
+
+ @override_settings(
+ TRENDING_BUCKET_RETENTION_HOURS=0,
+ )
+ def test_run_raises_on_invalid_retention_hours(self):
+ with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)):
+ with self.assertRaises(ValueError):
+ self.cron.run()
+
+ @override_settings(
+ TRENDING_BUCKET_RETENTION_HOURS=1,
+ TRENDING_MAX_WINDOW_MINUTES=60,
+ )
+ def test_run_raises_when_retention_cannot_cover_two_windows(self):
+ with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)):
+ with self.assertRaises(ValueError):
+ self.cron.run()
+
+ @override_settings(
+ TRENDING_BUCKET_RETENTION_HOURS=1,
+ TRENDING_MAX_WINDOW_MINUTES=120,
+ )
+ def test_run_raises_when_max_window_exceeds_retention_horizon(self):
+ with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)):
+ with self.assertRaises(ValueError):
+ self.cron.run()
+
+ @override_settings(
+ TRENDING_BUCKET_RETENTION_HOURS=4,
+ TRENDING_MAX_WINDOW_MINUTES=59,
+ )
+ def test_run_raises_when_max_window_below_60(self):
+ with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)):
+ with self.assertRaises(ValueError):
+ self.cron.run()
+
+ @override_settings(
+ TRENDING_BUCKET_RETENTION_HOURS=4,
+ TRENDING_MAX_WINDOW_MINUTES=130,
+ )
+ def test_run_raises_when_max_window_not_multiple_of_60(self):
+ with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)):
+ with self.assertRaises(ValueError):
+ self.cron.run()
+
+ def test_positive_int_setting_rejects_non_numeric(self):
+ with self.assertRaisesMessage(ValueError, "TRENDING_BUCKET_RETENTION_HOURS must be a positive integer"):
+ self.cron._positive_int_setting("TRENDING_BUCKET_RETENTION_HOURS", "abc")
diff --git a/tests/greedybear/management/test_setup_schedules.py b/tests/greedybear/management/test_setup_schedules.py
index 78b5384da..b46297e76 100644
--- a/tests/greedybear/management/test_setup_schedules.py
+++ b/tests/greedybear/management/test_setup_schedules.py
@@ -26,6 +26,10 @@ def test_extraction_interval_10(self, mock_schedule):
self.assertEqual(extract_call[1]["defaults"]["schedule_type"], Schedule.CRON)
self.assertEqual(extract_call[1]["defaults"]["cron"], "*/10 * * * *")
+ trending_cleanup_call = next(c for c in calls if c[1]["name"] == "clean_up_trending_buckets")
+ self.assertEqual(trending_cleanup_call[1]["defaults"]["schedule_type"], Schedule.CRON)
+ self.assertEqual(trending_cleanup_call[1]["defaults"]["cron"], "12 * * * *")
+
@patch("greedybear.cronjobs.schedules.Schedule")
@override_settings(EXTRACTION_INTERVAL=60)
def test_extraction_interval_60_clamps_minute(self, mock_schedule):
diff --git a/tests/greedybear/test_security_settings.py b/tests/greedybear/test_security_settings.py
new file mode 100644
index 000000000..96bb53802
--- /dev/null
+++ b/tests/greedybear/test_security_settings.py
@@ -0,0 +1,30 @@
+# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear
+# See the file 'LICENSE' for copying permission.
+from django.conf import settings
+from django.test import SimpleTestCase
+
+
+class SecuritySettingsTests(SimpleTestCase):
+ def test_content_type_nosniff_enabled(self):
+ """SECURE_CONTENT_TYPE_NOSNIFF should always be True."""
+ self.assertTrue(settings.SECURE_CONTENT_TYPE_NOSNIFF)
+
+ def test_x_frame_options_deny(self):
+ """X_FRAME_OPTIONS should be DENY to block all framing."""
+ self.assertEqual(settings.X_FRAME_OPTIONS, "DENY")
+
+ def test_ssl_redirect_not_set(self):
+ """SECURE_SSL_REDIRECT must not be enabled (TLS terminates at nginx)."""
+ self.assertFalse(getattr(settings, "SECURE_SSL_REDIRECT", False))
+
+ def test_cookie_security_matches_environment(self):
+ """Cookies should only be marked secure in production."""
+ is_production = getattr(settings, "STAGE_PRODUCTION", False)
+
+ # Test SESSION_COOKIE_SECURE
+ session_secure = getattr(settings, "SESSION_COOKIE_SECURE", False)
+ self.assertEqual(session_secure, is_production)
+
+ # Test CSRF_COOKIE_SECURE
+ csrf_secure = getattr(settings, "CSRF_COOKIE_SECURE", False)
+ self.assertEqual(csrf_secure, is_production)
diff --git a/tests/test_elastic_repository.py b/tests/test_elastic_repository.py
index 04b8e92d9..083456416 100644
--- a/tests/test_elastic_repository.py
+++ b/tests/test_elastic_repository.py
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta
from unittest.mock import Mock, call, patch
+from greedybear.consts import FIELDS_TO_EXTRACT
from greedybear.cronjobs.repositories import ElasticRepository, get_time_window
from . import CustomTestCase
@@ -113,6 +114,27 @@ def test_search_uses_time_window(self, mock_get_time_window, mock_search_class):
mock_get_time_window.assert_called_once()
+ @patch("greedybear.cronjobs.repositories.elastic.get_time_window")
+ @patch("greedybear.cronjobs.repositories.elastic.Search")
+ def test_search_scans_reassigned_source_filtered_search(self, mock_search_class, mock_get_time_window):
+ base_search = Mock()
+ filtered_search = Mock()
+ mock_search_class.return_value = base_search
+ base_search.query.return_value = base_search
+ base_search.source.return_value = filtered_search
+ filtered_search.scan.return_value = iter([{"@timestamp": 1}])
+ mock_get_time_window.return_value = (datetime(2025, 1, 1, 12, 0), datetime(2025, 1, 1, 12, 10))
+
+ chunks = list(self.repo.search(minutes_back_to_lookup=10))
+
+ self.assertEqual(chunks, [[{"@timestamp": 1}]])
+ base_search.source.assert_called_once_with(FIELDS_TO_EXTRACT)
+ filtered_search.scan.assert_called_once()
+ base_search.scan.assert_not_called()
+
+ def test_fields_to_extract_include_type_for_trending_bucketing(self):
+ self.assertIn("type", FIELDS_TO_EXTRACT)
+
class TestSearchChunking(CustomTestCase):
"""Tests for the chunked iteration behavior of search()."""
diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py
index ca5030913..2e1fa1c6d 100644
--- a/tests/test_extraction_utils.py
+++ b/tests/test_extraction_utils.py
@@ -693,7 +693,7 @@ def test_ioc_attacker_country_set_correctly(self):
self.assertEqual(ioc.interaction_count, 1)
def test_ioc_attacker_country_code_set_correctly(self):
- """Verify that iocs_from_hits extracts country_iso_code from geoip."""
+ """Verify that iocs_from_hits extracts country_code2 from geoip."""
hits = [
self._create_hit(
src_ip="8.8.8.8",
@@ -702,7 +702,7 @@ def test_ioc_attacker_country_code_set_correctly(self):
)
]
- hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NP"}
+ hits[0]["geoip"] = {"country_name": "Nepal", "country_code2": "NP"}
iocs = iocs_from_hits(hits)
self.assertEqual(len(iocs), 1)
@@ -712,7 +712,7 @@ def test_ioc_attacker_country_code_set_correctly(self):
self.assertEqual(ioc.attacker_country_code, "NP")
def test_ioc_attacker_country_code_defaults_to_empty(self):
- """Verify that attacker_country_code defaults to empty when geoip has no country_iso_code."""
+ """Verify that attacker_country_code defaults to empty when geoip has no country_code2."""
hits = [
self._create_hit(
src_ip="8.8.8.8",
@@ -740,7 +740,7 @@ def test_ioc_attacker_country_code_rejects_invalid_length(self):
)
]
- hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NPL"}
+ hits[0]["geoip"] = {"country_name": "Nepal", "country_code2": "NPL"}
iocs = iocs_from_hits(hits)
self.assertEqual(len(iocs), 1)
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 7c73287de..70afc8dec 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -71,6 +71,7 @@ def test_simple_wrapper_tasks(self):
("monitor_honeypots", "greedybear.cronjobs.monitor_honeypots.MonitorHoneypots"),
("monitor_logs", "greedybear.cronjobs.monitor_logs.MonitorLogs"),
("clean_up_db", "greedybear.cronjobs.cleanup.CleanUp"),
+ ("clean_up_trending_buckets", "greedybear.cronjobs.bucket_cleanup.TrendingBucketCleanupCron"),
("get_mass_scanners", "greedybear.cronjobs.mass_scanners.MassScannersCron"),
("get_whatsmyip", "greedybear.cronjobs.whatsmyip.WhatsMyIPCron"),
("extract_firehol_lists", "greedybear.cronjobs.firehol.FireHolCron"),
diff --git a/tests/test_trending_bucket_repository.py b/tests/test_trending_bucket_repository.py
new file mode 100644
index 000000000..fd1ff8a93
--- /dev/null
+++ b/tests/test_trending_bucket_repository.py
@@ -0,0 +1,136 @@
+from collections import Counter
+from datetime import datetime
+
+from greedybear.cronjobs.repositories.trending_bucket import TrendingBucketRepository
+from greedybear.models import AttackerActivityBucket
+from tests import CustomTestCase
+
+
+class TestTrendingBucketRepository(CustomTestCase):
+ def setUp(self):
+ super().setUp()
+ self.repo = TrendingBucketRepository()
+
+ def test_build_upsert_query_contains_expected_placeholders(self):
+ query = self.repo._build_upsert_query('"greedybear_attackeractivitybucket"', 3)
+
+ self.assertIn("INSERT INTO", query)
+ self.assertEqual(query.count("(%s, %s, %s, %s)"), 3)
+ self.assertIn("ON CONFLICT (attacker_ip, feed_type, bucket_start)", query)
+
+ def test_build_upsert_params_flattens_batch(self):
+ batch = [
+ (("1.1.1.1", "cowrie", datetime(2026, 3, 20, 9, 0)), 2),
+ (("2.2.2.2", "heralding", datetime(2026, 3, 20, 10, 0)), 4),
+ ]
+
+ params = self.repo._build_upsert_params(batch)
+
+ self.assertEqual(
+ params,
+ [
+ "1.1.1.1",
+ "cowrie",
+ datetime(2026, 3, 20, 9, 0),
+ 2,
+ "2.2.2.2",
+ "heralding",
+ datetime(2026, 3, 20, 10, 0),
+ 4,
+ ],
+ )
+
+ def test_normalize_feed_types_from_string(self):
+ self.assertEqual(self.repo._normalize_feed_types("cowrie"), ["cowrie"])
+
+ def test_normalize_feed_types_from_iterable(self):
+ self.assertEqual(self.repo._normalize_feed_types(("cowrie", "heralding")), ["cowrie", "heralding"])
+
+ def test_upsert_bucket_counts_returns_zero_for_empty_counter(self):
+ self.assertEqual(self.repo.upsert_bucket_counts(Counter()), 0)
+
+ def test_get_counts_in_window_filters_feed_types(self):
+ AttackerActivityBucket.objects.bulk_create(
+ [
+ AttackerActivityBucket(
+ attacker_ip="1.1.1.1",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=4,
+ ),
+ AttackerActivityBucket(
+ attacker_ip="1.1.1.1",
+ feed_type="heralding",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=2,
+ ),
+ AttackerActivityBucket(
+ attacker_ip="2.2.2.2",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=3,
+ ),
+ ]
+ )
+
+ counts = self.repo.get_counts_in_window(
+ window_start=datetime(2026, 3, 20, 9, 0),
+ window_end=datetime(2026, 3, 20, 10, 0),
+ feed_types=["cowrie"],
+ )
+
+ self.assertEqual(counts, {"1.1.1.1": 4, "2.2.2.2": 3})
+
+ def test_get_counts_in_window_with_all_feed_type_combines_totals(self):
+ AttackerActivityBucket.objects.bulk_create(
+ [
+ AttackerActivityBucket(
+ attacker_ip="1.1.1.1",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=4,
+ ),
+ AttackerActivityBucket(
+ attacker_ip="1.1.1.1",
+ feed_type="heralding",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=2,
+ ),
+ ]
+ )
+
+ counts = self.repo.get_counts_in_window(
+ window_start=datetime(2026, 3, 20, 9, 0),
+ window_end=datetime(2026, 3, 20, 10, 0),
+ feed_types="all",
+ )
+
+ self.assertEqual(counts, {"1.1.1.1": 6})
+
+ def test_get_counts_in_window_returns_empty_when_no_matches(self):
+ counts = self.repo.get_counts_in_window(
+ window_start=datetime(2026, 3, 20, 9, 0),
+ window_end=datetime(2026, 3, 20, 10, 0),
+ feed_types=["cowrie"],
+ )
+ self.assertEqual(counts, {})
+
+ def test_delete_older_than_removes_only_older_rows(self):
+ old_bucket = AttackerActivityBucket.objects.create(
+ attacker_ip="1.1.1.1",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 7, 0),
+ interaction_count=1,
+ )
+ fresh_bucket = AttackerActivityBucket.objects.create(
+ attacker_ip="2.2.2.2",
+ feed_type="cowrie",
+ bucket_start=datetime(2026, 3, 20, 9, 0),
+ interaction_count=1,
+ )
+
+ deleted_count = self.repo.delete_older_than(datetime(2026, 3, 20, 8, 0))
+
+ self.assertEqual(deleted_count, 1)
+ self.assertFalse(AttackerActivityBucket.objects.filter(id=old_bucket.id).exists())
+ self.assertTrue(AttackerActivityBucket.objects.filter(id=fresh_bucket.id).exists())
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f7bd78357..10be6b6dc 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,9 +1,11 @@
# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear
# See the file 'LICENSE' for copying permission.
+from ipaddress import ip_address
+
from django.test import SimpleTestCase
-from greedybear.utils import is_ip_address, is_sha256hash, is_valid_domain
+from greedybear.utils import is_ip_address, is_non_global_ip, is_sha256hash, is_valid_domain
class UtilsTestCase(SimpleTestCase):
@@ -45,3 +47,15 @@ def test_is_sha256hash(self):
self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8555")) # 65 chars
self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4g49b934ca495991b7852b855")) # Invalid char 'g'
self.assertFalse(is_sha256hash(""))
+
+ def test_is_non_global_ip(self):
+ self.assertTrue(is_non_global_ip(ip_address("127.0.0.1")))
+ self.assertTrue(is_non_global_ip(ip_address("10.0.0.1")))
+ self.assertTrue(is_non_global_ip(ip_address("169.254.1.1")))
+ self.assertTrue(is_non_global_ip(ip_address("224.0.0.1")))
+ self.assertTrue(is_non_global_ip(ip_address("240.0.0.1")))
+ self.assertTrue(is_non_global_ip(ip_address("::1")))
+ self.assertTrue(is_non_global_ip(ip_address("fc00::1")))
+
+ self.assertFalse(is_non_global_ip(ip_address("8.8.8.8")))
+ self.assertFalse(is_non_global_ip(ip_address("2001:4860:4860::8888")))
diff --git a/uv.lock b/uv.lock
index 613af8979..d05a98ba2 100644
--- a/uv.lock
+++ b/uv.lock
@@ -54,30 +54,30 @@ wheels = [
[[package]]
name = "boto3"
-version = "1.42.89"
+version = "1.42.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/bb/0c/f7bccb22b245cabf392816baba20f9e95f78ace7dbc580fd40136e80e732/boto3-1.42.89.tar.gz", hash = "sha256:3e43aacc0801bba9bcd23a8c271c089af297a69565f783fcdd357ae0e330bf1e", size = 113165, upload-time = "2026-04-13T19:36:17.516Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/dd/ac/e6b2b24d53c830500176f710594efcde626186b5b3c9aead6f8837976956/boto3-1.42.93.tar.gz", hash = "sha256:ff81c6bac708cb95c4f8b27e331ac67d95c6908dd86bcb7b15b8941960f2bc4c", size = 113218, upload-time = "2026-04-21T21:30:39.733Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b9/33/55103ba5ef9975ea54b8d39e69b76eb6e9fded3beae5f01065e26951a3a1/boto3-1.42.89-py3-none-any.whl", hash = "sha256:6204b189f4d0c655535f43d7eaa57ff4e8d965b8463c97e45952291211162932", size = 140556, upload-time = "2026-04-13T19:36:13.894Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2d/fcc35bde9fa47ac463a3023c73838e23e9281cde7f5e86fe7c459c3b72aa/boto3-1.42.93-py3-none-any.whl", hash = "sha256:51e34e30e65bea4df0ff77f91abdcb97297eb74c3b27eb576b2abbd758452967", size = 140554, upload-time = "2026-04-21T21:30:36.581Z" },
]
[[package]]
name = "botocore"
-version = "1.42.89"
+version = "1.42.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0f/cc/e6be943efa9051bd15c2ee14077c2b10d6e27c9e9385fc43a03a5c4ed8b5/botocore-1.42.89.tar.gz", hash = "sha256:95ac52f472dad29942f3088b278ab493044516c16dbf9133c975af16527baa99", size = 15206290, upload-time = "2026-04-13T19:36:02.321Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/d4/eb53f7ed81836696abf7103c9c901a0cace9217328094ca93419016a78c9/botocore-1.42.93.tar.gz", hash = "sha256:9ce49863c50b43f7942edd295fb16bfc6d227264ce6fc32c8f2426ef11b9351b", size = 15239759, upload-time = "2026-04-21T21:30:23.707Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/0c/ccc57c9a7bcd4553620bf6f50a3640ba68d189330fc4787dbddb2d851534/botocore-1.42.93-py3-none-any.whl", hash = "sha256:96ae26cd6302a7c7563398517b90a438168a4efdf4f73ab38882cefb8df721cc", size = 14923656, upload-time = "2026-04-21T21:30:17.597Z" },
]
[[package]]
@@ -109,11 +109,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.2.25"
+version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
+ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
@@ -194,15 +194,15 @@ wheels = [
[[package]]
name = "datasketch"
-version = "1.9.0"
+version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "scipy" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1f/d1/0b64dbc7626be277413daff298b9f026911389e75562952d5b7c662bbea1/datasketch-1.9.0.tar.gz", hash = "sha256:78d4560e415b0de11f595165887a0e4c9983d07b8f15dce9c53289a86bc12e92", size = 89790, upload-time = "2026-01-18T22:46:46.039Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699, upload-time = "2026-04-17T23:06:56.388Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/c8/ef06f4c9a0d7697c14c39475eaccd7d3774c34ecc693691079a21bd0d1f1/datasketch-1.9.0-py3-none-any.whl", hash = "sha256:48c18ae889862793609971c6d6d392d71c86793e838dbddd8aa53af6b1716e8a", size = 96542, upload-time = "2026-01-18T22:46:44.368Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914, upload-time = "2026-04-17T23:06:54.39Z" },
]
[[package]]
@@ -483,7 +483,7 @@ wheels = [
[[package]]
name = "greedybear"
-version = "3.3.2"
+version = "3.4.0"
source = { virtual = "." }
dependencies = [
{ name = "certego-saas" },
@@ -509,44 +509,46 @@ dependencies = [
[package.dev-dependencies]
dev = [
- { name = "coverage" },
- { name = "django-test-migrations" },
{ name = "django-watchfiles" },
]
lint = [
{ name = "ruff" },
]
+test = [
+ { name = "coverage" },
+ { name = "django-test-migrations" },
+]
[package.metadata]
requires-dist = [
{ name = "certego-saas", specifier = "==0.7.12" },
- { name = "croniter", specifier = "==6.2.2" },
- { name = "datasketch", specifier = "==1.9.0" },
- { name = "django", specifier = "==5.2.13" },
- { name = "django-q2", specifier = "==1.9.0" },
+ { name = "croniter", specifier = "~=6.2" },
+ { name = "datasketch", specifier = "~=1.10" },
+ { name = "django", specifier = "~=5.2.13" },
+ { name = "django-q2", specifier = "~=1.9" },
{ name = "django-rest-email-auth", specifier = "==5.0.0" },
- { name = "django-ses", specifier = "==4.7.2" },
- { name = "djangorestframework", specifier = "==3.17.1" },
+ { name = "django-ses", specifier = "~=4.7" },
+ { name = "djangorestframework", specifier = "~=3.17.1" },
{ name = "elasticsearch", specifier = "==9.3.0" },
- { name = "feedparser", specifier = "==6.0.12" },
- { name = "gunicorn", specifier = "==25.3.0" },
- { name = "joblib", specifier = "==1.5.3" },
- { name = "numpy", specifier = "==2.4.4" },
- { name = "pandas", specifier = "==3.0.2" },
- { name = "psycopg", extras = ["c"], specifier = "==3.3.3" },
- { name = "requests", specifier = "==2.33.1" },
- { name = "scikit-learn", specifier = "==1.8.0" },
- { name = "slack-sdk", specifier = "==3.41.0" },
- { name = "stix2", specifier = "==3.0.2" },
+ { name = "feedparser", specifier = "~=6.0" },
+ { name = "gunicorn", specifier = "~=25.3" },
+ { name = "joblib", specifier = "~=1.5" },
+ { name = "numpy", specifier = "~=2.4" },
+ { name = "pandas", specifier = "~=3.0" },
+ { name = "psycopg", extras = ["c"], specifier = "~=3.3" },
+ { name = "requests", specifier = "~=2.33" },
+ { name = "scikit-learn", specifier = "~=1.8.0" },
+ { name = "slack-sdk", specifier = "~=3.41" },
+ { name = "stix2", specifier = "~=3.0" },
]
[package.metadata.requires-dev]
-dev = [
+dev = [{ name = "django-watchfiles", specifier = "~=1.4" }]
+lint = [{ name = "ruff", specifier = "==0.15.11" }]
+test = [
{ name = "coverage", specifier = "==7.13.5" },
{ name = "django-test-migrations", specifier = "==1.5.0" },
- { name = "django-watchfiles", specifier = "==1.4.0" },
]
-lint = [{ name = "ruff", specifier = "==0.15.10" }]
[[package]]
name = "gunicorn"
@@ -571,11 +573,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.11"
+version = "3.13"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
@@ -881,27 +883,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.15.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
- { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
- { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
- { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
- { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
- { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
- { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
- { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
- { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
- { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
- { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
- { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
- { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
- { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
- { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
- { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
- { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
+version = "0.15.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
+ { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
+ { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
+ { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
+ { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
+ { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
]
[[package]]
@@ -981,24 +983,22 @@ sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711
[[package]]
name = "simplejson"
-version = "3.20.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" },
- { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" },
- { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" },
- { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" },
- { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" },
- { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" },
- { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" },
- { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" },
- { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" },
- { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" },
- { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" },
- { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" },
- { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" },
+version = "4.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/14/91/b0e7a38d63706dde006d1213f9c394ad7702df841c019fb4cf0e3295c58c/simplejson-4.0.1.tar.gz", hash = "sha256:bc13170567a5c856a0e6c16620c0b0388722f7d6382acd8007857624c3dedf3e", size = 115959, upload-time = "2026-04-18T22:46:17.544Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/94/afe6285b3b39208473ab9056039cf20cac393d1a7942f644ecb7d424463b/simplejson-4.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f934a918ef7a50698a481e18aa713d3075f94d1402a6d293f755d00118f8a975", size = 110975, upload-time = "2026-04-18T22:44:55.995Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/45/2c74fdb851af00c18ae11af978e7191972071e26ffe2f5aaccbd0a96e961/simplejson-4.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2b0211da4be9fcbabe11ff9b65f7e06dd8205197b078fe7f1e9cc268cd0e369", size = 90384, upload-time = "2026-04-18T22:44:57.194Z" },
+ { url = "https://files.pythonhosted.org/packages/92/af/f457958ef90935e99e1bdf51c16b2a0448726a4db2881d472d22f0259290/simplejson-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:58c125ee57f2a35081c46ca66d76ee3c109394f4aee0d981cb049c5ee5688e62", size = 90387, upload-time = "2026-04-18T22:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/43/58/da3da211a3b91ff0f8b9a3926e3b97b9224cbb94e4a67b998394c0e984a3/simplejson-4.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:24ca278f0ba7b3aff90ed1f873f5f4192ae1da97de72196b4be021f3b1d407a0", size = 186193, upload-time = "2026-04-18T22:45:00.108Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ff/5757f7eddea36d55593423c5da72cdb1ffd2ae7755d804da08f598cf277a/simplejson-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a6b3d15ab9c8247f7e0a280b2823eff08bd74e7605701c01d7cd54e80a86ed", size = 183647, upload-time = "2026-04-18T22:45:01.484Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/27/eb59ff58a78e57db4120e10bc6b7d6409f9c25180b912593c2cfe5637b5c/simplejson-4.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:534a7d90b86a97cf15ebe42d732c41223612d981d08f226025499177692a977d", size = 191880, upload-time = "2026-04-18T22:45:03.067Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/e6/0650672c0b43b9add2c2e5f13f5b2d3770b23bc10688dfe4ea637ef1a4b4/simplejson-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e2b10899f200b675a8ab0e2abd4c4846d2631d90d226fc3079e8b16073aba31d", size = 180312, upload-time = "2026-04-18T22:45:04.51Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b9/4a4042c8f24bb53ca03a2d5d794aeefa78965df1213239cf9fe895b40342/simplejson-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dd525b897132493aa65eecd3c5ddc3602e25c625caafbcc958fc87a74a24a1c1", size = 188278, upload-time = "2026-04-18T22:45:06.019Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b4/594be43d55ea5100334bc60013c3b310ecde4135b8288dd8183d21881966/simplejson-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:072a481be83b8c3f9167d0463105e0ddfcf2e00a6289c2ad650c3b35920ce0c8", size = 183928, upload-time = "2026-04-18T22:45:07.298Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/26/8c09ba0fcf0b8c284a4f0f57605890c443b59cec070c6918baa7600976d9/simplejson-4.0.1-cp313-cp313-win32.whl", hash = "sha256:91eb4b42ab0a2de89919ed3bfa960fca0b13af0816447c89123c6658c36fb0a1", size = 88305, upload-time = "2026-04-18T22:45:08.603Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/18/996ef6a0bd5b02bb9880901df01621b6c6de599360454a2ee2d06985076f/simplejson-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d2dc202a5e9d07e893c0bc1aa12b88bff98332c52b3ae184defe054d0e2ff35", size = 90273, upload-time = "2026-04-18T22:45:09.774Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/2d/93a5b862ac29f182a658eb3fc2c98fe28acbb5c05a9076402572c7eb6966/simplejson-4.0.1-py3-none-any.whl", hash = "sha256:dfa6e9923c0ec2880738d09e5ce045741eb6cd4551e261dcd6c3625d26666075", size = 69242, upload-time = "2026-04-18T22:46:16.183Z" },
]
[[package]]