From 1c94e8ab7287333e4c1f3bd9e7d3abbde62c357e Mon Sep 17 00:00:00 2001 From: Drona Raj Gyawali Date: Sat, 17 Jan 2026 16:29:44 +0000 Subject: [PATCH 1/4] feat(api): add ASN-aggregated IOC statistics --- api/urls.py | 2 ++ api/views/feeds.py | 46 +++++++++++++++++++++++++++++++++++++ api/views/utils.py | 55 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_views.py | 56 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+) diff --git a/api/urls.py b/api/urls.py index 7202fc10..f426151e 100644 --- a/api/urls.py +++ b/api/urls.py @@ -10,6 +10,7 @@ enrichment_view, feeds, feeds_advanced, + feeds_asn, feeds_pagination, general_honeypot_list, ) @@ -22,6 +23,7 @@ urlpatterns = [ path("feeds/", feeds_pagination), path("feeds/advanced/", feeds_advanced), + path("feeds/asn/", feeds_asn), path("feeds///.", feeds), path("enrichment", enrichment_view), path("cowrie_session", cowrie_session_view), diff --git a/api/views/feeds.py b/api/views/feeds.py index 617df2ac..a0e771ae 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -10,9 +10,11 @@ permission_classes, ) from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from api.views.utils import ( FeedRequestParams, + aggregate_iocs_by_asn, feeds_response, get_queryset, get_valid_feed_types, @@ -116,3 +118,47 @@ def feeds_advanced(request): resp_data = feeds_response(iocs, feed_params, valid_feed_types, dict_only=True, verbose=verbose) return paginator.get_paginated_response(resp_data) return feeds_response(iocs_queryset, feed_params, valid_feed_types, verbose=verbose) + + +@api_view(["GET"]) +@authentication_classes([CookieTokenAuthentication]) +@permission_classes([IsAuthenticated]) +def feeds_asn(request): + """ + Handle requests for IOC feeds aggregated by ASN, calculating summary statistics. + + This endpoint groups IOC data by ASN and returns aggregated metrics for each ASN, including counts, sums, + and unique honeypots. + + Args: + request: The incoming request object. + feed_type (str): Type of feed to retrieve. Supported: `cowrie`, `log4j`, general honeypot names, or `all`. Default: `all`. + attack_type (str): Type of attack to filter. Supported: `scanner`, `payload_request`, or `all`. Default: `all`. + max_age (int): Maximum number of days since last occurrence. Default: 3. + min_days_seen (int): Minimum number of days an IOC must have been seen. Default: 1. + include_reputation (str): `;`-separated list of reputations to include, e.g., `known attacker;`. Default: include all. + exclude_reputation (str): `;`-separated list of reputations to exclude, e.g., `mass scanner;bot`. Default: exclude none. + feed_size (int): Maximum number of IOC items considered for aggregation. Default: 5000. + ordering (str): Field to order results by, with optional `-` prefix for descending. Default: `-last_seen`. + verbose (bool): Not used in this endpoint; included for consistency with Advanced Feeds API. Default: `false`. + paginate (bool): Not supported in this endpoint; ignored if provided. Default: `false`. + format (str): Response format type; only `json` is supported. Default: `json`. + + Returns: + Response: HTTP response with a JSON list of ASN aggregation objects. + Each object contains: + asn (int): ASN number. + ioc_count (int): Number of IOCs for this ASN. + total_attack_count (int): Sum of attack_count for all IOCs. + total_interaction_count (int): Sum of interaction_count for all IOCs. + total_login_attempts (int): Sum of login_attempts for all IOCs. + honeypots (List[str]): Sorted list of unique honeypots that observed these IOCs. + expected_ioc_count (float): Sum of recurrence_probability for all IOCs, rounded to 4 decimals. + expected_interactions (float): Sum of expected_interactions for all IOCs, rounded to 4 decimals. + """ + logger.info(f"request /api/feeds/asn/ with params: {request.query_params}") + feed_params = FeedRequestParams(request.query_params) + valid_feed_types = get_valid_feed_types() + iocs_queryset = get_queryset(request, feed_params, valid_feed_types) + response_data = aggregate_iocs_by_asn(iocs_queryset) + return Response(response_data) diff --git a/api/views/utils.py b/api/views/utils.py index 87face9d..2695220d 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -3,6 +3,7 @@ import csv import logging import re +from collections import defaultdict from datetime import datetime, timedelta from ipaddress import ip_address @@ -326,3 +327,57 @@ def is_sha256hash(string: str) -> bool: bool: True if the string is a valid SHA-256 hash, False otherwise """ return bool(re.fullmatch(r"^[A-Fa-f0-9]{64}$", string)) + + +def aggregate_iocs_by_asn(iocs): + """ + Aggregate IOC objects by ASN, computing counts, sums, and unique honeypots. + + Args: + iocs (Iterable[IOC]): QuerySet or list of IOC objects to aggregate. + + Returns: + List[dict]: Each dictionary contains ASN-level statistics: + """ + aggregated = defaultdict( + lambda: { + "ioc_count": 0, + "attack": 0, + "interactions": 0, + "logins": 0, + "honeypots": set(), + "exp_ioc": 0.0, + "exp_inter": 0.0, + } + ) + + for ioc in iocs: + asn = ioc.asn + if asn is None: + continue + + e = aggregated[asn] + + e["ioc_count"] += 1 + e["attack"] += ioc.attack_count or 0 + e["interactions"] += ioc.interaction_count or 0 + e["logins"] += ioc.login_attempts or 0 + e["exp_ioc"] += ioc.recurrence_probability or 0.0 + e["exp_inter"] += ioc.expected_interactions or 0.0 + + if getattr(ioc, "honeypots", None): + e["honeypots"].update(ioc.honeypots) + + return [ + { + "asn": asn, + "ioc_count": e["ioc_count"], + "total_attack_count": e["attack"], + "total_interaction_count": e["interactions"], + "total_login_attempts": e["logins"], + "honeypots": sorted(filter(None, e["honeypots"])), + "expected_ioc_count": round(e["exp_ioc"], 4), + "expected_interactions": round(e["exp_inter"], 4), + } + for asn, e in aggregated.items() + ] diff --git a/tests/test_views.py b/tests/test_views.py index 3b20b4e6..bde5c5d3 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -265,6 +265,62 @@ def test_400_feeds_pagination(self): self.assertEqual(response.status_code, 400) +class FeedsASNViewTestCase(CustomTestCase): + """Tests for the ASN aggregated feed endpoint.""" + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(user=self.superuser) + + # this endpoint returns raw aggregated data + # and does not include license or pagination metadata. + def test_200_asn_feed_basic(self): + """Test that the ASN feed returns aggregated statistics for existing ASNs.""" + response = self.client.get("/api/feeds/asn/") + self.assertEqual(response.status_code, 200) + + data = response.json() + asn_int = int(self.ioc.asn) + asn_item = next((item for item in data if item["asn"] == asn_int), None) + self.assertIsNotNone(asn_item) + + # expected results are sum of self.ioc, self.ioc_2, self.ioc_3 because they share same asn + self.assertEqual(asn_item["ioc_count"], 3) + self.assertEqual(asn_item["total_attack_count"], 3) + self.assertEqual(asn_item["total_interaction_count"], 3) + self.assertEqual(asn_item["total_login_attempts"], 3) + # honeypots: filter out None + self.assertCountEqual(asn_item["honeypots"], ["Heralding", "Ciscoasa"]) + # sum recurrence_probability + self.assertAlmostEqual(asn_item["expected_ioc_count"], 0.3) + # sum expected_interactions + self.assertAlmostEqual(asn_item["expected_interactions"], 33.3) + + def test_200_asn_feed_with_filter(self): + """Test filtering by honeypot or other query parameters (if supported).""" + response = self.client.get("/api/feeds/asn/?honeypot=Heralding") + self.assertEqual(response.status_code, 200) + + data = response.json() + asn_int = int(self.ioc.asn) + asn_item = next((item for item in data if item["asn"] == asn_int), None) + self.assertIsNotNone(asn_item) + # only honeypots that match the filter are included here + self.assertIn("Heralding", asn_item["honeypots"]) + self.assertNotIn("Ddospot", asn_item["honeypots"]) + + def test_400_asn_feed_invalid_param(self): + """Invalid query param returns 400.""" + response = self.client.get("/api/feeds/asn/?attack_type=invalid") + self.assertEqual(response.status_code, 400) + + def test_401_asn_feed_unauthenticated(self): + """Unauthenticated requests are rejected.""" + self.client.logout() + response = self.client.get("/api/feeds/asn/") + self.assertEqual(response.status_code, 401) + + class StatisticsViewTestCase(CustomTestCase): @classmethod def setUpClass(cls): From 1445edf2e69a18b634cd5deefbc95e4f7749b091 Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Thu, 22 Jan 2026 22:07:25 +0545 Subject: [PATCH 2/4] refactor: db level aggregation --- api/serializers.py | 28 ++++++ api/views/feeds.py | 39 ++++---- api/views/utils.py | 112 +++++++++++++---------- tests/test_views.py | 210 +++++++++++++++++++++++++++++++++++--------- 4 files changed, 278 insertions(+), 111 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 83b9da1a..1408640a 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -118,6 +118,34 @@ def validate_ordering(self, ordering): return ordering_validation(ordering) +# NOTE: While the FeedsRequestSerializer enforces ordering on db +# Model fields, aggregation requires ordering by non-model, which is annotated fields +class ASNFeedsOrderingSerializer(FeedsRequestSerializer): + ALLOWED_ORDERING_FIELDS = frozenset( + { + "asn", + "ioc_count", + "total_attack_count", + "total_interaction_count", + "total_login_attempts", + "expected_ioc_count", + "expected_interactions", + "first_seen", + "last_seen", + } + ) + + def validate_ordering(self, ordering): + field_name = ordering.lstrip("-").strip() + + if field_name not in self.ALLOWED_ORDERING_FIELDS: + raise serializers.ValidationError( + {f"Invalid ordering field for ASN aggregated feed: '{field_name}'. Allowed fields: {', '.join(sorted(self.ALLOWED_ORDERING_FIELDS))}"} + ) + + return ordering + + class FeedsResponseSerializer(serializers.Serializer): """ Serializer for feed response data structure. diff --git a/api/views/feeds.py b/api/views/feeds.py index a0e771ae..c6e56524 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -12,9 +12,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from api.serializers import ASNFeedsOrderingSerializer from api.views.utils import ( FeedRequestParams, - aggregate_iocs_by_asn, + asn_aggregated_queryset, feeds_response, get_queryset, get_valid_feed_types, @@ -125,24 +126,17 @@ def feeds_advanced(request): @permission_classes([IsAuthenticated]) def feeds_asn(request): """ - Handle requests for IOC feeds aggregated by ASN, calculating summary statistics. - - This endpoint groups IOC data by ASN and returns aggregated metrics for each ASN, including counts, sums, - and unique honeypots. + Retrieve aggregated IOC feed data grouped by ASN (Autonomous System Number). Args: - request: The incoming request object. - feed_type (str): Type of feed to retrieve. Supported: `cowrie`, `log4j`, general honeypot names, or `all`. Default: `all`. - attack_type (str): Type of attack to filter. Supported: `scanner`, `payload_request`, or `all`. Default: `all`. - max_age (int): Maximum number of days since last occurrence. Default: 3. - min_days_seen (int): Minimum number of days an IOC must have been seen. Default: 1. - include_reputation (str): `;`-separated list of reputations to include, e.g., `known attacker;`. Default: include all. - exclude_reputation (str): `;`-separated list of reputations to exclude, e.g., `mass scanner;bot`. Default: exclude none. - feed_size (int): Maximum number of IOC items considered for aggregation. Default: 5000. - ordering (str): Field to order results by, with optional `-` prefix for descending. Default: `-last_seen`. - verbose (bool): Not used in this endpoint; included for consistency with Advanced Feeds API. Default: `false`. - paginate (bool): Not supported in this endpoint; ignored if provided. Default: `false`. - format (str): Response format type; only `json` is supported. Default: `json`. + request: The HTTP request object. + feed_type (str): Filter by feed type (e.g., 'cowrie', 'log4j'). Default: 'all'. + attack_type (str): Filter by attack type (e.g., 'scanner', 'payload_request'). Default: 'all'. + max_age (int): Maximum age of IOCs in days. Default: 3. + min_days_seen (int): Minimum days an IOC must have been observed. Default: 1. + exclude_reputation (str): ';'-separated reputations to exclude (e.g., 'mass scanner'). Default: none. + ordering (str): Aggregation ordering field (e.g., 'total_attack_count', 'asn'). Default: '-ioc_count'. + asn (str, optional): Filter results to a single ASN. Returns: Response: HTTP response with a JSON list of ASN aggregation objects. @@ -155,10 +149,15 @@ def feeds_asn(request): honeypots (List[str]): Sorted list of unique honeypots that observed these IOCs. expected_ioc_count (float): Sum of recurrence_probability for all IOCs, rounded to 4 decimals. expected_interactions (float): Sum of expected_interactions for all IOCs, rounded to 4 decimals. + first_seen (DateTime): Earliest first_seen timestamp among IOCs. + last_seen (DateTime): Latest last_seen timestamp among IOCs. """ logger.info(f"request /api/feeds/asn/ with params: {request.query_params}") feed_params = FeedRequestParams(request.query_params) valid_feed_types = get_valid_feed_types() - iocs_queryset = get_queryset(request, feed_params, valid_feed_types) - response_data = aggregate_iocs_by_asn(iocs_queryset) - return Response(response_data) + + iocs_qs = get_queryset(request, feed_params, valid_feed_types, is_aggregated=True, serializer_class=ASNFeedsOrderingSerializer) + + asn_aggregates = asn_aggregated_queryset(iocs_qs, request, feed_params) + data = list(asn_aggregates) + return Response(data) diff --git a/api/views/utils.py b/api/views/utils.py index 2695220d..fad28324 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -3,13 +3,12 @@ import csv import logging import re -from collections import defaultdict from datetime import datetime, timedelta from ipaddress import ip_address from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import F, Q +from django.db.models import Count, F, Max, Min, Q, Sum from django.http import HttpResponse, HttpResponseBadRequest, StreamingHttpResponse from rest_framework import status from rest_framework.response import Response @@ -329,55 +328,72 @@ def is_sha256hash(string: str) -> bool: return bool(re.fullmatch(r"^[A-Fa-f0-9]{64}$", string)) -def aggregate_iocs_by_asn(iocs): +def resolve_aggregation_ordering(ordering, *, default, fallback_fields=None): """ - Aggregate IOC objects by ASN, computing counts, sums, and unique honeypots. + Resolve effective ordering for aggregated endpoints. - Args: - iocs (Iterable[IOC]): QuerySet or list of IOC objects to aggregate. + Args + ordering (str or None): The user-provided ordering string from query params. + default (str): The default ordering to use if `ordering` is None or in fallback_fields. + fallback_fields (set[str], optional): A set of orderings that are allowed in other + contexts but should be overridden here. Defaults to None. - Returns: - List[dict]: Each dictionary contains ASN-level statistics: + Returns + str: A safe ordering string to use directly in the aggregation query. + """ + fallback_fields = fallback_fields or set() + + if not ordering or ordering in fallback_fields: + return default + + return ordering + + +def asn_aggregated_queryset(iocs_qs, request, feed_params): """ - aggregated = defaultdict( - lambda: { - "ioc_count": 0, - "attack": 0, - "interactions": 0, - "logins": 0, - "honeypots": set(), - "exp_ioc": 0.0, - "exp_inter": 0.0, - } + Perform DB-level aggregation grouped by ASN. + + Args + iocs_qs (QuerySet): Filtered IOC queryset from get_queryset; + request (Request): The API request object; + feed_params (FeedRequestParams): Validated parameter object + + Returns: A values-grouped queryset with annotated metrics and honeypot arrays. + """ + # optional asn params for single asn filter + asn_filter = request.query_params.get("asn") + if asn_filter: + iocs_qs = iocs_qs.filter(asn=asn_filter) + + aggregated = ( + iocs_qs.exclude(asn__isnull=True) + .values("asn") + .annotate( + ioc_count=Count("id", distinct=True), + total_attack_count=Sum("attack_count", distinct=True), + total_interaction_count=Sum("interaction_count", distinct=True), + total_login_attempts=Sum("login_attempts", distinct=True), + expected_ioc_count=Sum("recurrence_probability", distinct=True), + expected_interactions=Sum("expected_interactions", distinct=True), + honeypots=ArrayAgg( + "general_honeypot__name", + filter=Q(general_honeypot__name__isnull=False), + distinct=True, + ), + first_seen=Min("first_seen"), + last_seen=Max("last_seen"), + ) ) - for ioc in iocs: - asn = ioc.asn - if asn is None: - continue - - e = aggregated[asn] - - e["ioc_count"] += 1 - e["attack"] += ioc.attack_count or 0 - e["interactions"] += ioc.interaction_count or 0 - e["logins"] += ioc.login_attempts or 0 - e["exp_ioc"] += ioc.recurrence_probability or 0.0 - e["exp_inter"] += ioc.expected_interactions or 0.0 - - if getattr(ioc, "honeypots", None): - e["honeypots"].update(ioc.honeypots) - - return [ - { - "asn": asn, - "ioc_count": e["ioc_count"], - "total_attack_count": e["attack"], - "total_interaction_count": e["interactions"], - "total_login_attempts": e["logins"], - "honeypots": sorted(filter(None, e["honeypots"])), - "expected_ioc_count": round(e["exp_ioc"], 4), - "expected_interactions": round(e["exp_inter"], 4), - } - for asn, e in aggregated.items() - ] + resolved_ordering = resolve_aggregation_ordering( + ordering=feed_params.ordering, + default="-ioc_count", + fallback_fields={"-last_seen"}, + ) + + direction = "-" if resolved_ordering.startswith("-") else "" + field = resolved_ordering.lstrip("-").strip() + + aggregated = aggregated.order_by(f"{direction}{field}") + + return aggregated diff --git a/tests/test_views.py b/tests/test_views.py index bde5c5d3..b852e36b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,9 +1,10 @@ from django.conf import settings from django.test import override_settings +from django.utils import timezone from rest_framework.test import APIClient from api.views.utils import is_ip_address, is_sha256hash -from greedybear.models import GeneralHoneypot, Statistics, ViewType +from greedybear.models import IOC, GeneralHoneypot, Statistics, ViewType from . import CustomTestCase @@ -266,59 +267,182 @@ def test_400_feeds_pagination(self): class FeedsASNViewTestCase(CustomTestCase): - """Tests for the ASN aggregated feed endpoint.""" + """Tests for ASN aggregated feeds API""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + IOC.objects.all().delete() + cls.testpot1, _ = GeneralHoneypot.objects.get_or_create(name="testpot1", active=True) + cls.testpot2, _ = GeneralHoneypot.objects.get_or_create(name="testpot2", active=True) + + cls.high_asn = "13335" + cls.low_asn = "16276" + + cls.ioc_high1 = IOC.objects.create( + name="high1.example.com", + type="ip", + asn=cls.high_asn, + attack_count=15, + interaction_count=30, + login_attempts=5, + first_seen=timezone.now() - timezone.timedelta(days=10), + recurrence_probability=0.8, + expected_interactions=20.0, + ) + cls.ioc_high1.general_honeypot.add(cls.testpot1, cls.testpot2) + cls.ioc_high1.save() + + cls.ioc_high2 = IOC.objects.create( + name="high2.example.com", + type="ip", + asn=cls.high_asn, + attack_count=5, + interaction_count=10, + login_attempts=2, + first_seen=timezone.now() - timezone.timedelta(days=5), + recurrence_probability=0.3, + expected_interactions=8.0, + ) + cls.ioc_high2.general_honeypot.add(cls.testpot1, cls.testpot2) + cls.ioc_high2.save() + + cls.ioc_low = IOC.objects.create( + name="low.example.com", + type="ip", + asn=cls.low_asn, + attack_count=2, + interaction_count=5, + login_attempts=1, + first_seen=timezone.now(), + recurrence_probability=0.1, + expected_interactions=3.0, + ) + cls.ioc_low.general_honeypot.add(cls.testpot1, cls.testpot2) + cls.ioc_low.save() def setUp(self): self.client = APIClient() self.client.force_authenticate(user=self.superuser) + self.url = "/api/feeds/asn/" - # this endpoint returns raw aggregated data - # and does not include license or pagination metadata. - def test_200_asn_feed_basic(self): - """Test that the ASN feed returns aggregated statistics for existing ASNs.""" - response = self.client.get("/api/feeds/asn/") + def _get_results(self, response): + payload = response.json() + self.assertIsInstance(payload, list) + return payload + + def test_200_asn_feed_aggregated_fields(self): + """Ensure aggregated fields are computed correctly per ASN using dynamic sums""" + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) + results = self._get_results(response) - data = response.json() - asn_int = int(self.ioc.asn) - asn_item = next((item for item in data if item["asn"] == asn_int), None) - self.assertIsNotNone(asn_item) - - # expected results are sum of self.ioc, self.ioc_2, self.ioc_3 because they share same asn - self.assertEqual(asn_item["ioc_count"], 3) - self.assertEqual(asn_item["total_attack_count"], 3) - self.assertEqual(asn_item["total_interaction_count"], 3) - self.assertEqual(asn_item["total_login_attempts"], 3) - # honeypots: filter out None - self.assertCountEqual(asn_item["honeypots"], ["Heralding", "Ciscoasa"]) - # sum recurrence_probability - self.assertAlmostEqual(asn_item["expected_ioc_count"], 0.3) - # sum expected_interactions - self.assertAlmostEqual(asn_item["expected_interactions"], 33.3) - - def test_200_asn_feed_with_filter(self): - """Test filtering by honeypot or other query parameters (if supported).""" - response = self.client.get("/api/feeds/asn/?honeypot=Heralding") + # filtering high ASN + high_item = next((item for item in results if str(item["asn"]) == self.high_asn), None) + self.assertIsNotNone(high_item) + + # getting all IOCs for high ASN from the DB + high_iocs = IOC.objects.filter(asn=self.high_asn) + + self.assertEqual(high_item["ioc_count"], high_iocs.count()) + self.assertEqual(high_item["total_attack_count"], sum(i.attack_count for i in high_iocs)) + self.assertEqual(high_item["total_interaction_count"], sum(i.interaction_count for i in high_iocs)) + self.assertEqual(high_item["total_login_attempts"], sum(i.login_attempts for i in high_iocs)) + self.assertAlmostEqual(high_item["expected_ioc_count"], sum(i.recurrence_probability for i in high_iocs)) + self.assertAlmostEqual(high_item["expected_interactions"], sum(i.expected_interactions for i in high_iocs)) + + # validating first_seen / last_seen dynamically + self.assertEqual(high_item["first_seen"], min(i.first_seen for i in high_iocs).isoformat()) + self.assertEqual(high_item["last_seen"], max(i.last_seen for i in high_iocs).isoformat()) + + # validating honeypots dynamically + expected_honeypots = sorted({hp.name for i in high_iocs for hp in i.general_honeypot.all()}) + self.assertEqual(sorted(high_item["honeypots"]), expected_honeypots) + + def test_200_asn_feed_default_ordering(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + + # high_asn has ioc_count=2 > low_asn ioc_count=1 + self.assertEqual(str(results[0]["asn"]), self.high_asn) + self.assertEqual(str(results[1]["asn"]), self.low_asn) + + def test_200_asn_feed_ordering_desc_ioc_count(self): + response = self.client.get(self.url + "?ordering=-ioc_count") self.assertEqual(response.status_code, 200) + results = self._get_results(response) + self.assertEqual(str(results[0]["asn"]), self.high_asn) + + def test_200_asn_feed_ordering_asc_ioc_count(self): + response = self.client.get(self.url + "?ordering=ioc_count") + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + self.assertEqual(str(results[0]["asn"]), self.low_asn) + + def test_200_asn_feed_ordering_desc_interaction_count(self): + response = self.client.get(self.url + "?ordering=-total_interaction_count") + self.assertEqual(response.status_code, 200) + results = self._get_results(response) + self.assertEqual(str(results[0]["asn"]), self.high_asn) + + def test_200_asn_feed_with_asn_filter(self): + response = self.client.get(self.url + f"?asn={self.high_asn}") + self.assertEqual(response.status_code, 200) + + results = self._get_results(response) + self.assertEqual(len(results), 1) + self.assertEqual(str(results[0]["asn"]), self.high_asn) + + def test_400_asn_feed_invalid_ordering_honeypots(self): + response = self.client.get(self.url + "?ordering=honeypots") + self.assertEqual(response.status_code, 400) data = response.json() - asn_int = int(self.ioc.asn) - asn_item = next((item for item in data if item["asn"] == asn_int), None) - self.assertIsNotNone(asn_item) - # only honeypots that match the filter are included here - self.assertIn("Heralding", asn_item["honeypots"]) - self.assertNotIn("Ddospot", asn_item["honeypots"]) - - def test_400_asn_feed_invalid_param(self): - """Invalid query param returns 400.""" - response = self.client.get("/api/feeds/asn/?attack_type=invalid") + errors_container = data.get("errors", data) + error_list = errors_container.get("ordering", []) + self.assertTrue(error_list) + error_msg = error_list[0].lower() + self.assertIn("honeypots", error_msg) + self.assertIn("invalid", error_msg) + + def test_400_asn_feed_invalid_ordering_random(self): + response = self.client.get(self.url + "?ordering=xyz123") self.assertEqual(response.status_code, 400) - - def test_401_asn_feed_unauthenticated(self): - """Unauthenticated requests are rejected.""" - self.client.logout() - response = self.client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, 401) + data = response.json() + errors_container = data.get("errors", data) + error_list = errors_container.get("ordering", []) + self.assertTrue(error_list) + error_msg = error_list[0].lower() + self.assertIn("xyz123", error_msg) + self.assertIn("invalid", error_msg) + + def test_400_asn_feed_invalid_ordering_model_field_not_in_agg(self): + response = self.client.get(self.url + "?ordering=attack_count") + self.assertEqual(response.status_code, 400) + data = response.json() + errors_container = data.get("errors", data) + error_list = errors_container.get("ordering", []) + self.assertTrue(error_list) + error_msg = error_list[0].lower() + self.assertIn("attack_count", error_msg) + self.assertIn("invalid", error_msg) + + def test_400_asn_feed_ordering_empty_param(self): + response = self.client.get(self.url + "?ordering=") + self.assertEqual(response.status_code, 400) + data = response.json() + errors_container = data.get("errors", data) + error_list = errors_container.get("ordering", []) + self.assertTrue(error_list) + error_msg = error_list[0].lower() + self.assertIn("blank", error_msg) + + def test_asn_feed_ignores_feed_size(self): + response = self.client.get(self.url + "?feed_size=1") + results = response.json() + # aggregation should return all ASNs regardless of feed_size + self.assertEqual(len(results), 2) class StatisticsViewTestCase(CustomTestCase): From 9ff126824fee77f83d35fdd8f0ae1a1206c7e583 Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Thu, 22 Jan 2026 22:17:56 +0545 Subject: [PATCH 3/4] refactor: missing args --- api/views/utils.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/api/views/utils.py b/api/views/utils.py index fad28324..bcecf3fd 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -121,7 +121,7 @@ def get_valid_feed_types() -> frozenset[str]: return frozenset([Honeypots.LOG4J.value, Honeypots.COWRIE.value, "all"] + [hp.name.lower() for hp in general_honeypots]) -def get_queryset(request, feed_params, valid_feed_types): +def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, serializer_class=FeedsRequestSerializer): """ Build a queryset to filter IOC data based on the request parameters. @@ -129,6 +129,15 @@ def get_queryset(request, feed_params, valid_feed_types): request: The incoming request object. feed_params: A FeedRequestParams instance. valid_feed_types (frozenset): The set of all valid feed types. + is_aggregated (bool, optional): + - If True, disables slicing (`feed_size`) and model-level ordering. + - Ensures full dataset is available for aggregation or specialized computation. + - Default: False. + serializer_class (class, optional): + - Serializer class used to validate request parameters. + - Allows injecting a custom serializer to enforce rules for specific feed types + (e.g., to restrict ordering fields or validation for specialized feeds). + - Default: `FeedsRequestSerializer`. Returns: QuerySet: The filtered queryset of IOC data. @@ -139,7 +148,7 @@ def get_queryset(request, feed_params, valid_feed_types): f"Age: {feed_params.max_age}, format: {feed_params.format}" ) - serializer = FeedsRequestSerializer( + serializer = serializer_class( data=vars(feed_params), context={"valid_feed_types": valid_feed_types}, ) @@ -171,9 +180,14 @@ def get_queryset(request, feed_params, valid_feed_types): .exclude(ip_reputation__in=feed_params.exclude_reputation) .annotate(value=F("name")) .annotate(honeypots=ArrayAgg("general_honeypot__name")) - .order_by(feed_params.ordering)[: int(feed_params.feed_size)] ) + # aggregated endpoints should operate on the full queryset + # to compute sums, counts, and other metrics correctly. + if not is_aggregated: + iocs = iocs.order_by(feed_params.ordering) + iocs = iocs[: int(feed_params.feed_size)] + # save request source for statistics source_ip = str(request.META["REMOTE_ADDR"]) request_source = Statistics(source=source_ip) From c65af2d2d5056e3fe893124cc150fbd0d4919cf4 Mon Sep 17 00:00:00 2001 From: Dorna Raj Gyawali Date: Thu, 22 Jan 2026 22:36:57 +0545 Subject: [PATCH 4/4] resolve linter issue --- api/views/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/views/utils.py b/api/views/utils.py index a5dc7b5b..d2b53da5 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -8,7 +8,6 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg - from django.db.models import Count, F, Max, Min, Q, Sum from django.http import HttpResponse, HttpResponseBadRequest, StreamingHttpResponse from rest_framework import status