Skip to content

Commit 78eae80

Browse files
Expose credential_count in IOC feeds and enable filtering by credential usage . Closes GreedyBear-Project#1294 (GreedyBear-Project#1295)
* added credential_count Signed-off-by: Drona Raj Gyawali <dronarajgyawali@gmail.com> * endpoint only for advanc. feed Signed-off-by: Drona Raj Gyawali <dronarajgyawali@gmail.com> * added edgecases --------- Signed-off-by: Drona Raj Gyawali <dronarajgyawali@gmail.com>
1 parent 08b33df commit 78eae80

4 files changed

Lines changed: 83 additions & 4 deletions

File tree

api/serializers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ class FeedsRequestSerializer(serializers.Serializer):
132132
ioc_type = serializers.ChoiceField(choices=["ip", "domain", "all"])
133133
max_age = serializers.IntegerField(min_value=1)
134134
min_days_seen = serializers.IntegerField(min_value=1)
135+
min_credential_count = serializers.IntegerField(required=False, min_value=1)
136+
max_credential_count = serializers.IntegerField(required=False, min_value=0)
135137
include_reputation = serializers.ListField(child=serializers.CharField(max_length=120))
136138
exclude_reputation = serializers.ListField(child=serializers.CharField(max_length=120))
137139
feed_size = serializers.IntegerField(min_value=1)
@@ -167,6 +169,13 @@ def validate_ordering(self, ordering):
167169
logger.debug(f"FeedsRequestSerializer - validation ordering: '{ordering}'")
168170
return ordering_validation(ordering)
169171

172+
def validate(self, data):
173+
min_cc = data.get("min_credential_count")
174+
max_cc = data.get("max_credential_count")
175+
if min_cc is not None and max_cc is not None and min_cc > max_cc:
176+
raise serializers.ValidationError("min_credential_count must be less than or equal to max_credential_count")
177+
return data
178+
170179

171180
class ASNFeedsOrderingSerializer(FeedsRequestSerializer):
172181
ALLOWED_ORDERING_FIELDS = frozenset(

api/views/feeds.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ def feeds_advanced(request):
127127
attack_type (str): Type of attack to filter. (supported: `scanner`, `payload_request`, `all`; default: `all`)
128128
max_age (int): Maximum number of days since last occurrence. E.g. an IOC that was last seen 4 days ago is excluded by default. (default: 3)
129129
min_days_seen (int): Minimum number of days on which an IOC must have been seen. (default: 1)
130+
min_credential_count (int, optional): Filter IOCs with at least this many distinct credentials. (default: no filter)
131+
max_credential_count (int, optional): Filter IOCs with at most this many distinct credentials. (default: no filter)
130132
include_reputation (str): `;`-separated list of reputation values to include, e.g. `known attacker` or `known attacker;` to include IOCs without reputation. (default: include all)
131133
exclude_reputation (str): `;`-separated list of reputation values to exclude, e.g. `mass scanner` or `mass scanner;bot, crawler`. (default: exclude none)
132134
feed_size (int): Number of IOC items to return. (default: 5000)
@@ -154,6 +156,7 @@ def feeds_advanced(request):
154156
tag_key=request.query_params.get("tag_key", "").strip(),
155157
tag_value=request.query_params.get("tag_value", "").strip(),
156158
include_sensors=True,
159+
include_credential_count=True,
157160
)
158161
if paginate:
159162
paginator = CustomPageNumberPagination()

api/views/utils.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ def __init__(self, query_params: dict):
131131
self.start_date = query_params.get("start_date")
132132
self.end_date = query_params.get("end_date")
133133
self.country_code = query_params.get("country_code")
134+
self.min_credential_count = query_params.get("min_credential_count")
135+
self.max_credential_count = query_params.get("max_credential_count")
134136

135137
def apply_default_filters(self, query_params):
136138
if not query_params:
@@ -177,7 +179,15 @@ def get_valid_feed_types() -> frozenset[str]:
177179

178180

179181
def get_queryset(
180-
request, feed_params, valid_feed_types, is_aggregated=False, serializer_class=FeedsRequestSerializer, tag_key="", tag_value="", include_sensors=False
182+
request,
183+
feed_params,
184+
valid_feed_types,
185+
is_aggregated=False,
186+
serializer_class=FeedsRequestSerializer,
187+
tag_key="",
188+
tag_value="",
189+
include_sensors=False,
190+
include_credential_count=False,
181191
):
182192
"""
183193
Build a queryset to filter IOC data based on the request parameters.
@@ -199,7 +209,8 @@ def get_queryset(
199209
tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring). Only passed from feeds_advanced.
200210
include_sensors (bool, optional): If True, annotates sensors_json for each IOC.
201211
Only passed from authenticated views like feeds_advanced. Default: False.
202-
212+
include_credential_count (bool, optional): If True, annotates credential Count for each IOC.
213+
Only passed from authenticated views like feeds_advanced. Default: False.
203214
Returns:
204215
QuerySet: The filtered queryset of IOC data.
205216
"""
@@ -257,6 +268,16 @@ def get_queryset(
257268

258269
iocs = IOC.objects.filter(**query_dict).exclude(ip_reputation__in=feed_params.exclude_reputation).annotate(value=F("name")).distinct()
259270

271+
# credential count filtering is only available on the advanced feed
272+
if include_credential_count:
273+
iocs = iocs.annotate(credential_count=Count("credentials", distinct=True))
274+
min_credential_count = serializer.validated_data.get("min_credential_count")
275+
max_credential_count = serializer.validated_data.get("max_credential_count")
276+
if min_credential_count is not None:
277+
iocs = iocs.filter(credential_count__gte=min_credential_count)
278+
if max_credential_count is not None:
279+
iocs = iocs.filter(credential_count__lte=max_credential_count)
280+
260281
# apply feed type filter as union;
261282
if "all" not in feed_params.feed_types:
262283
type_filter = Q()
@@ -355,6 +376,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
355376
"first_seen",
356377
"last_seen",
357378
"attack_count",
379+
"credential_count",
358380
"interaction_count",
359381
"scanner",
360382
"payload_request",
@@ -384,11 +406,13 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N
384406
if isinstance(iocs, list):
385407
has_tags_annotation = bool(iocs) and hasattr(iocs[0], "tags_json")
386408
has_sensors_annotation = include_sensors and bool(iocs) and hasattr(iocs[0], "sensors_json")
409+
has_credential_count = bool(iocs) and hasattr(iocs[0], "credential_count")
387410
else:
388411
has_tags_annotation = "tags_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations
389412
has_sensors_annotation = include_sensors and "sensors_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations
390-
413+
has_credential_count = "credential_count" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations
391414
required_fields = tuple(("tags_json" if f == "tags" else f) for f in required_fields if f != "tags" or has_tags_annotation)
415+
required_fields = tuple(f for f in required_fields if f != "credential_count" or has_credential_count)
392416
if has_sensors_annotation:
393417
required_fields = (*required_fields, "sensors_json")
394418

tests/api/views/test_feeds_advanced_view.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from rest_framework.test import APIClient
99

1010
from api.throttles import SharedFeedRateThrottle
11-
from greedybear.models import IOC, AutonomousSystem, IocType, Sensor, ShareToken
11+
from greedybear.models import IOC, AutonomousSystem, Credential, IocType, Sensor, ShareToken
1212
from tests import CustomTestCase
1313

1414

@@ -115,6 +115,11 @@ def test_200_feed_contains_attacker_country_code(self):
115115
self.assertIsNotNone(target_ioc)
116116
self.assertEqual(target_ioc["attacker_country_code"], "NP")
117117

118+
def test_400_invalid_credential_count_range(self):
119+
"""min_credential_count greater than max_credential_count returns 400."""
120+
response = self.client.get("/api/feeds/advanced/?min_credential_count=10&max_credential_count=5")
121+
self.assertEqual(response.status_code, 400)
122+
118123
def test_feeds_advanced_includes_sensors(self):
119124
"""Sensors field appears in feeds_advanced response for authenticated users."""
120125
sensor = Sensor.objects.create(address="10.0.0.1", label="test-sensor")
@@ -140,6 +145,44 @@ def test_public_feeds_excludes_sensors(self):
140145
for ioc in iocs:
141146
self.assertNotIn("sensors", ioc)
142147

148+
def test_min_credential_count_filter(self):
149+
"""IOCs with fewer credentials than min_credential_count are excluded."""
150+
cred1 = Credential.objects.create(username="admin", password="admin")
151+
cred2 = Credential.objects.create(username="root", password="1234")
152+
self.ioc.credentials.add(cred1, cred2)
153+
154+
response = self.client.get("/api/feeds/advanced/?min_credential_count=2")
155+
self.assertEqual(response.status_code, 200)
156+
iocs = response.json()["iocs"]
157+
target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None)
158+
self.assertIsNotNone(target_ioc)
159+
self.assertGreaterEqual(target_ioc["credential_count"], 2)
160+
161+
def test_max_credential_count_filter(self):
162+
"""IOCs with more credentials than max_credential_count are excluded."""
163+
cred1 = Credential.objects.create(username="admin", password="admin")
164+
cred2 = Credential.objects.create(username="root", password="1234")
165+
self.ioc.credentials.add(cred1, cred2)
166+
167+
response = self.client.get("/api/feeds/advanced/?max_credential_count=1")
168+
self.assertEqual(response.status_code, 200)
169+
iocs = response.json()["iocs"]
170+
target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None)
171+
# self.ioc has 2 credentials so it should be excluded
172+
self.assertIsNone(target_ioc)
173+
174+
def test_credential_count_in_response(self):
175+
"""credential_count field is present in JSON response when filtering by credential count."""
176+
cred1 = Credential.objects.create(username="admin", password="admin")
177+
self.ioc.credentials.add(cred1)
178+
response = self.client.get("/api/feeds/advanced/")
179+
self.assertEqual(response.status_code, 200)
180+
iocs = response.json()["iocs"]
181+
target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None)
182+
self.assertIsNotNone(target_ioc)
183+
self.assertIn("credential_count", target_ioc)
184+
self.assertEqual(target_ioc["credential_count"], 1)
185+
143186

144187
class FeedsEnhancementsTestCase(CustomTestCase):
145188
"""Tests for advanced filtering, STIX export, and shareable feeds functionality."""

0 commit comments

Comments
 (0)