From 367b20c16a97e701632dc83ec61340767fbcfffb Mon Sep 17 00:00:00 2001 From: Krishna Awasthi <140143710+opbot-xd@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:59:11 +0530 Subject: [PATCH 01/75] feat: make feed license configurable via environment variable (#616) * feat: make feed license configurable via environment variable - Move FEEDS_LICENSE from hardcoded constant to optional environment variable - Update settings.py to read FEEDS_LICENSE from environment - Add FEEDS_LICENSE configuration to env_file_template with example - Modify API views to only include license field when FEEDS_LICENSE is set - Update tests to handle both scenarios (with/without license configured) - This allows self-hosted instances to use different licenses or none at all Fixes #599 * fix: update FEEDS_LICENSE import in command_sequence and cowrie_session views * refactor: simplify license handling to use FEEDS_LICENSE constant directly * test: add explicit tests for FEEDS_LICENSE populated and empty scenarios * fix: use settings.FEEDS_LICENSE instead of direct import for @override_settings compatibility The @override_settings decorator only works when accessing settings through django.conf.settings, not with direct imports. This fixes test failures where FEEDS_LICENSE was imported directly from greedybear.settings. Changes: - api/views/utils.py: Import settings and use settings.FEEDS_LICENSE - api/views/command_sequence.py: Import settings and use settings.FEEDS_LICENSE - api/views/cowrie_session.py: Import settings and use settings.FEEDS_LICENSE - tests/test_views.py: Import settings and use settings.FEEDS_LICENSE This ensures tests with @override_settings(FEEDS_LICENSE="...") work correctly. --- api/views/command_sequence.py | 9 ++- api/views/cowrie_session.py | 6 +- api/views/utils.py | 17 +++--- docker/env_file_template | 7 ++- greedybear/consts.py | 2 - greedybear/settings.py | 4 ++ tests/test_views.py | 108 +++++++++++++++++++++++++++++++--- 7 files changed, 128 insertions(+), 25 deletions(-) diff --git a/api/views/command_sequence.py b/api/views/command_sequence.py index efbf550f..5e75e019 100644 --- a/api/views/command_sequence.py +++ b/api/views/command_sequence.py @@ -4,8 +4,9 @@ from api.views.utils import is_ip_address, is_sha256hash from certego_saas.apps.auth.backend import CookieTokenAuthentication +from django.conf import settings from django.http import Http404, HttpResponseBadRequest -from greedybear.consts import FEEDS_LICENSE, GET +from greedybear.consts import GET from greedybear.models import IOC, CommandSequence, CowrieSession, Statistics, viewType from rest_framework import status from rest_framework.decorators import api_view, authentication_classes, permission_classes @@ -66,10 +67,11 @@ def command_sequence_view(request): if not seqs: raise Http404(f"No command sequences found for IP: {observable}") data = { - "license": FEEDS_LICENSE, "executed_commands": seqs, "executed_by": sorted([ioc.name for ioc in related_iocs]), } + if settings.FEEDS_LICENSE: + data["license"] = settings.FEEDS_LICENSE return Response(data, status=status.HTTP_200_OK) if is_sha256hash(observable): @@ -86,10 +88,11 @@ def command_sequence_view(request): for s in sessions ] data = { - "license": FEEDS_LICENSE, "commands": commands, "iocs": sorted(iocs, key=lambda d: d["time"], reverse=True), } + if settings.FEEDS_LICENSE: + data["license"] = settings.FEEDS_LICENSE return Response(data, status=status.HTTP_200_OK) except CommandSequence.DoesNotExist as exc: raise Http404(f"No command sequences found with hash: {observable}") from exc diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index 9ddb0b4c..7c0b5299 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -6,8 +6,9 @@ from api.views.utils import is_ip_address, is_sha256hash from certego_saas.apps.auth.backend import CookieTokenAuthentication +from django.conf import settings from django.http import Http404, HttpResponseBadRequest -from greedybear.consts import FEEDS_LICENSE, GET +from greedybear.consts import GET from greedybear.models import IOC, CommandSequence, CowrieSession, Statistics, viewType from rest_framework import status from rest_framework.decorators import api_view, authentication_classes, permission_classes @@ -94,9 +95,10 @@ def cowrie_session_view(request): sessions = sessions.union(related_sessions) response_data = { - "license": FEEDS_LICENSE, "query": observable, } + if settings.FEEDS_LICENSE: + response_data["license"] = settings.FEEDS_LICENSE unique_commands = set(s.commands for s in sessions if s.commands) response_data["commands"] = sorted("\n".join(cmd.commands) for cmd in unique_commands) diff --git a/api/views/utils.py b/api/views/utils.py index 39c2ae1c..ed121dd2 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -8,12 +8,11 @@ from api.enums import Honeypots from api.serializers import FeedsRequestSerializer, FeedsResponseSerializer +from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import F, Q from django.http import HttpResponse, HttpResponseBadRequest, StreamingHttpResponse -from greedybear.consts import FEEDS_LICENSE from greedybear.models import IOC, GeneralHoneypot, Statistics -from greedybear.settings import EXTRACTION_INTERVAL from rest_framework import status from rest_framework.response import Response @@ -207,16 +206,14 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose Response: The HTTP response containing formatted IOC data. """ logger.info(f"Format feeds in: {feed_params.format}") - license_text = ( - f"# These feeds are generated by The Honeynet Project once every {EXTRACTION_INTERVAL} minutes " - f"and are protected by the following license: {FEEDS_LICENSE}" - ) match feed_params.format: case "txt": - text_lines = [license_text] + [ioc[0] for ioc in iocs.values_list("name")] + text_lines = [f"# {settings.FEEDS_LICENSE}"] if settings.FEEDS_LICENSE else [] + text_lines += [ioc[0] for ioc in iocs.values_list("name")] return HttpResponse("\n".join(text_lines), content_type="text/plain") case "csv": - rows = [[license_text]] + [list(ioc) for ioc in iocs.values_list("name")] + rows = [[f"# {settings.FEEDS_LICENSE}"]] if settings.FEEDS_LICENSE else [] + rows += [list(ioc) for ioc in iocs.values_list("name")] pseudo_buffer = Echo() writer = csv.writer(pseudo_buffer, quoting=csv.QUOTE_NONE) return StreamingHttpResponse( @@ -280,7 +277,9 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose json_list = sorted(json_list, key=lambda k: k["feed_type"], reverse=feed_params.feed_type_sorting == "-feed_type") logger.info(f"Number of feeds returned: {len(json_list)}") - resp_data = {"license": FEEDS_LICENSE, "iocs": json_list} + resp_data = {"iocs": json_list} + if settings.FEEDS_LICENSE: + resp_data["license"] = settings.FEEDS_LICENSE if dict_only: return resp_data else: diff --git a/docker/env_file_template b/docker/env_file_template index d7622bf4..884cdd00 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -63,4 +63,9 @@ COMMAND_SEQUENCE_RETENTION = 365 # ThreatFox API key. # Once added, your payload request domains will be submitted to ThreatFox -THREATFOX_API_KEY = \ No newline at end of file +THREATFOX_API_KEY = + +# Optional feed license URL to include in API responses +# If not set, no license information will be included in feeds +# Example: https://github.com/honeynet/GreedyBear/blob/main/FEEDS_LICENSE.md +FEEDS_LICENSE= \ No newline at end of file diff --git a/greedybear/consts.py b/greedybear/consts.py index 376b7b56..82cc4caf 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -6,8 +6,6 @@ GET = "GET" POST = "POST" -FEEDS_LICENSE = "https://github.com/honeynet/GreedyBear/blob/main/FEEDS_LICENSE.md" - REGEX_DOMAIN = r"^[a-zA-Z\d-]{1,60}(\.[a-zA-Z\d-]{1,60})*$" REGEX_IP = r"^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$" REGEX_PASSWORD = r"^[a-zA-Z0-9]{12,}$" diff --git a/greedybear/settings.py b/greedybear/settings.py index cb986fbd..78b31e5d 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -415,3 +415,7 @@ COMMAND_SEQUENCE_RETENTION = int(os.environ.get("COMMAND_SEQUENCE_RETENTION", "365")) THREATFOX_API_KEY = os.environ.get("THREATFOX_API_KEY", "") + +# Optional feed license URL to include in API responses +# If not set, no license information will be included in feeds +FEEDS_LICENSE = os.environ.get("FEEDS_LICENSE", "") diff --git a/tests/test_views.py b/tests/test_views.py index e6d9c587..8eabc640 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,5 +1,6 @@ from api.views.utils import is_ip_address, is_sha256hash -from greedybear.consts import FEEDS_LICENSE +from django.conf import settings +from django.test import override_settings from greedybear.models import GeneralHoneypot, Statistics, viewType from rest_framework.test import APIClient @@ -57,10 +58,13 @@ def test_for_invalid_authentication(self): class FeedsViewTestCase(CustomTestCase): - def test_200_all_feeds(self): - response = self.client.get("/api/feeds/all/all/recent.json") + def test_200_log4j_feeds(self): + response = self.client.get("/api/feeds/log4j/all/recent.json") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["license"], FEEDS_LICENSE) + if settings.FEEDS_LICENSE: + self.assertEqual(response.json()["license"], settings.FEEDS_LICENSE) + else: + self.assertNotIn("license", response.json()) self.assertEqual(response.json()["iocs"][0]["feed_type"], ["log4j", "cowrie", "heralding", "ciscoasa"]) self.assertEqual(response.json()["iocs"][0]["attack_count"], 1) self.assertEqual(response.json()["iocs"][0]["scanner"], True) @@ -68,10 +72,28 @@ def test_200_all_feeds(self): self.assertEqual(response.json()["iocs"][0]["recurrence_probability"], self.ioc.recurrence_probability) self.assertEqual(response.json()["iocs"][0]["expected_interactions"], self.ioc.expected_interactions) + @override_settings(FEEDS_LICENSE="https://example.com/license") + def test_200_all_feeds_with_license(self): + """Test feeds endpoint when FEEDS_LICENSE is populated""" + response = self.client.get("/api/feeds/all/all/recent.json") + self.assertEqual(response.status_code, 200) + self.assertIn("license", response.json()) + self.assertEqual(response.json()["license"], "https://example.com/license") + + @override_settings(FEEDS_LICENSE="") + def test_200_all_feeds_without_license(self): + """Test feeds endpoint when FEEDS_LICENSE is empty""" + response = self.client.get("/api/feeds/all/all/recent.json") + self.assertEqual(response.status_code, 200) + self.assertNotIn("license", response.json()) + def test_200_general_feeds(self): response = self.client.get("/api/feeds/heralding/all/recent.json") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["license"], FEEDS_LICENSE) + if settings.FEEDS_LICENSE: + self.assertEqual(response.json()["license"], settings.FEEDS_LICENSE) + else: + self.assertNotIn("license", response.json()) self.assertEqual(response.json()["iocs"][0]["feed_type"], ["log4j", "cowrie", "heralding", "ciscoasa"]) self.assertEqual(response.json()["iocs"][0]["attack_count"], 1) self.assertEqual(response.json()["iocs"][0]["scanner"], True) @@ -82,7 +104,10 @@ def test_200_general_feeds(self): def test_200_feeds_scanner_inclusion(self): response = self.client.get("/api/feeds/heralding/all/recent.json?include_mass_scanners") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["license"], FEEDS_LICENSE) + if settings.FEEDS_LICENSE: + self.assertEqual(response.json()["license"], settings.FEEDS_LICENSE) + else: + self.assertNotIn("license", response.json()) self.assertEqual(len(response.json()["iocs"]), 2) def test_400_feeds(self): @@ -123,7 +148,10 @@ def setUp(self): def test_200_all_feeds(self): response = self.client.get("/api/feeds/advanced/") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["license"], FEEDS_LICENSE) + if settings.FEEDS_LICENSE: + self.assertEqual(response.json()["license"], settings.FEEDS_LICENSE) + else: + self.assertNotIn("license", response.json()) self.assertEqual(response.json()["iocs"][0]["feed_type"], ["log4j", "cowrie", "heralding", "ciscoasa"]) self.assertEqual(response.json()["iocs"][0]["attack_count"], 1) self.assertEqual(response.json()["iocs"][0]["scanner"], True) @@ -134,7 +162,10 @@ def test_200_all_feeds(self): def test_200_general_feeds(self): response = self.client.get("/api/feeds/advanced/?feed_type=heralding") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["license"], FEEDS_LICENSE) + if settings.FEEDS_LICENSE: + self.assertEqual(response.json()["license"], settings.FEEDS_LICENSE) + else: + self.assertNotIn("license", response.json()) self.assertEqual(response.json()["iocs"][0]["feed_type"], ["log4j", "cowrie", "heralding", "ciscoasa"]) self.assertEqual(response.json()["iocs"][0]["attack_count"], 1) self.assertEqual(response.json()["iocs"][0]["scanner"], True) @@ -297,6 +328,36 @@ def test_nonexistent_hash(self): response = self.client.get(f"/api/command_sequence?query={'f' * 64}") self.assertEqual(response.status_code, 404) + @override_settings(FEEDS_LICENSE="https://example.com/license") + def test_ip_address_query_with_license(self): + """Test that license is included when FEEDS_LICENSE is populated.""" + response = self.client.get("/api/command_sequence?query=140.246.171.141") + self.assertEqual(response.status_code, 200) + self.assertIn("license", response.data) + self.assertEqual(response.data["license"], "https://example.com/license") + + @override_settings(FEEDS_LICENSE="") + def test_ip_address_query_without_license(self): + """Test that license is not included when FEEDS_LICENSE is empty.""" + response = self.client.get("/api/command_sequence?query=140.246.171.141") + self.assertEqual(response.status_code, 200) + self.assertNotIn("license", response.data) + + @override_settings(FEEDS_LICENSE="https://example.com/license") + def test_hash_query_with_license(self): + """Test that license is included when FEEDS_LICENSE is populated.""" + response = self.client.get(f"/api/command_sequence?query={self.hash}") + self.assertEqual(response.status_code, 200) + self.assertIn("license", response.data) + self.assertEqual(response.data["license"], "https://example.com/license") + + @override_settings(FEEDS_LICENSE="") + def test_hash_query_without_license(self): + """Test that license is not included when FEEDS_LICENSE is empty.""" + response = self.client.get(f"/api/command_sequence?query={self.hash}") + self.assertEqual(response.status_code, 200) + self.assertNotIn("license", response.data) + class CowrieSessionViewTestCase(CustomTestCase): """Test cases for the cowrie_session_view.""" @@ -467,6 +528,37 @@ def test_query_with_url_encoding(self): # Should either work or return 400, not crash self.assertIn(response.status_code, [200, 400, 404]) + # # # # # License Tests # # # # # + @override_settings(FEEDS_LICENSE="https://example.com/license") + def test_ip_query_with_license(self): + """Test that license is included when FEEDS_LICENSE is populated.""" + response = self.client.get("/api/cowrie_session?query=140.246.171.141") + self.assertEqual(response.status_code, 200) + self.assertIn("license", response.data) + self.assertEqual(response.data["license"], "https://example.com/license") + + @override_settings(FEEDS_LICENSE="") + def test_ip_query_without_license(self): + """Test that license is not included when FEEDS_LICENSE is empty.""" + response = self.client.get("/api/cowrie_session?query=140.246.171.141") + self.assertEqual(response.status_code, 200) + self.assertNotIn("license", response.data) + + @override_settings(FEEDS_LICENSE="https://example.com/license") + def test_hash_query_with_license(self): + """Test that license is included when FEEDS_LICENSE is populated.""" + response = self.client.get(f"/api/cowrie_session?query={self.hash}") + self.assertEqual(response.status_code, 200) + self.assertIn("license", response.data) + self.assertEqual(response.data["license"], "https://example.com/license") + + @override_settings(FEEDS_LICENSE="") + def test_hash_query_without_license(self): + """Test that license is not included when FEEDS_LICENSE is empty.""" + response = self.client.get(f"/api/cowrie_session?query={self.hash}") + self.assertEqual(response.status_code, 200) + self.assertNotIn("license", response.data) + def test_query_with_special_characters(self): """Test handling of queries with special characters.""" response = self.client.get("/api/cowrie_session?query=") From e1f835abb863db4200d0b97f35b37cde95419306 Mon Sep 17 00:00:00 2001 From: Krishna Awasthi <140143710+opbot-xd@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:30:24 +0530 Subject: [PATCH 02/75] feat: add IOC type filter to Feeds API and page (#617) * feat: add IOC type filter to Feeds API and page - Add optional ioc_type parameter (ip/domain/all) to FeedRequestParams - Update FeedsRequestSerializer with ioc_type field validation - Modify get_queryset to filter IOCs by type when ioc_type is specified - Add IOC type dropdown in Feeds page UI with three options - Update frontend to include ioc_type in API calls and URL generation - Add test data with domain IOC for comprehensive testing - Add test cases for IP-only, domain-only, and all IOC type filters This enhancement allows users to filter feeds specifically by IP addresses or domains, making it easier to showcase domains extracted from payload requests separately from IP addresses. Closes #551 * fix: wrap Prioritize field in Col component to fix JSX syntax error * test: add frontend tests for IOC type filter * Fix test_valid_fields by adding missing required ioc_type field * Fix failing tests and bugs: Update assertions, fix feeds filtering, improve validation 1. Synced Tests with Existing Test Data: Updated assertions to expect 3 IOCs for Heralding instead of 2, matching the setupTestData. Refactored tests to find IOCs by value. 2. Fixed Feed Filtering Bug: Updated feeds view to correctly pass query parameters (like ioc_type) to FeedRequestParams. 3. Improved Input Validation: Added check in serializers to reject invalid IP strings that were being accepted as domains. * refactor: adjust form column widths and consolidate form groups in the Feeds component. --- api/serializers.py | 5 +- api/views/feeds.py | 4 +- api/views/utils.py | 5 + frontend/src/components/feeds/Feeds.jsx | 35 +++++- .../tests/components/feeds/Feeds.test.jsx | 16 ++- tests/__init__.py | 24 ++++ tests/test_serializers.py | 1 + tests/test_views.py | 119 +++++++++++++----- 8 files changed, 168 insertions(+), 41 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 917a0f44..8be204e7 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -38,7 +38,9 @@ def validate(self, data): Check a given observable against regex expression """ observable = data["query"] - if not re.match(REGEX_IP, observable) or not re.match(REGEX_DOMAIN, observable): + if re.match(r"^[\d\.]+$", observable) and not re.match(REGEX_IP, observable): + raise serializers.ValidationError("Observable is not a valid IP") + if not re.match(REGEX_IP, observable) and not re.match(REGEX_DOMAIN, observable): raise serializers.ValidationError("Observable is not a valid IP or domain") try: required_object = IOC.objects.get(name=observable) @@ -95,6 +97,7 @@ def ordering_validation(ordering: str) -> str: class FeedsRequestSerializer(serializers.Serializer): feed_type = serializers.CharField(max_length=120) attack_type = serializers.ChoiceField(choices=["scanner", "payload_request", "all"]) + ioc_type = serializers.ChoiceField(choices=["ip", "domain", "all"]) max_age = serializers.IntegerField(min_value=1) min_days_seen = serializers.IntegerField(min_value=1) include_reputation = serializers.ListField(child=serializers.CharField(max_length=120)) diff --git a/api/views/feeds.py b/api/views/feeds.py index 5e309d11..1e953e26 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -33,7 +33,9 @@ def feeds(request, feed_type, attack_type, prioritize, format_): """ logger.info(f"request /api/feeds with params: feed type: {feed_type}, " f"attack_type: {attack_type}, prioritization: {prioritize}, format: {format_}") - feed_params = FeedRequestParams({"feed_type": feed_type, "attack_type": attack_type, "format_": format_}) + feed_params_data = request.query_params.dict() + feed_params_data.update({"feed_type": feed_type, "attack_type": attack_type, "format_": format_}) + feed_params = FeedRequestParams(feed_params_data) feed_params.apply_default_filters(request.query_params) feed_params.set_prioritization(prioritize) diff --git a/api/views/utils.py b/api/views/utils.py index ed121dd2..6f1325d2 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -45,6 +45,7 @@ class FeedRequestParams: Attributes: feed_type (str): Type of feed to retrieve (default: "all") attack_type (str): Type of attack to filter (default: "all") + ioc_type (str): Type of IOC to filter - 'ip', 'domain', or 'all' (default: "all") max_age (str): Maximum number of days since last occurrence (default: "3") min_days_seen (str): Minimum number of days on which an IOC must have been seen (default: "1") include_reputation (list): List of reputation values to include (default: []) @@ -64,6 +65,7 @@ def __init__(self, query_params: dict): """ self.feed_type = query_params.get("feed_type", "all").lower() self.attack_type = query_params.get("attack_type", "all").lower() + self.ioc_type = query_params.get("ioc_type", "all").lower() self.max_age = query_params.get("max_age", "3") self.min_days_seen = query_params.get("min_days_seen", "1") self.include_reputation = query_params["include_reputation"].split(";") if "include_reputation" in query_params else [] @@ -153,6 +155,9 @@ def get_queryset(request, feed_params, valid_feed_types): if feed_params.attack_type != "all": query_dict[feed_params.attack_type] = True + if feed_params.ioc_type != "all": + query_dict["type"] = feed_params.ioc_type + query_dict["last_seen__gte"] = datetime.now() - timedelta(days=int(feed_params.max_age)) if int(feed_params.min_days_seen) > 1: query_dict["number_of_days_seen__gte"] = int(feed_params.min_days_seen) diff --git a/frontend/src/components/feeds/Feeds.jsx b/frontend/src/components/feeds/Feeds.jsx index 314d3b3c..6b03bfe7 100644 --- a/frontend/src/components/feeds/Feeds.jsx +++ b/frontend/src/components/feeds/Feeds.jsx @@ -26,6 +26,12 @@ const attackTypeChoices = [ { label: "Payload request", value: "payload_request" }, ]; +const iocTypeChoices = [ + { label: "All", value: "all" }, + { label: "IP addresses", value: "ip" }, + { label: "Domains", value: "domain" }, +]; + const prioritizationChoices = [ { label: "Recent", value: "recent" }, { label: "Persistent", value: "persistent" }, @@ -36,6 +42,7 @@ const prioritizationChoices = [ const initialValues = { feeds_type: "all", attack_type: "all", + ioc_type: "all", prioritize: "recent", }; @@ -87,6 +94,7 @@ export default function Feeds() { params: { feed_type: initialValues.feeds_type, attack_type: initialValues.attack_type, + ioc_type: initialValues.ioc_type, prioritize: initialValues.prioritize, }, initialParams: { @@ -102,10 +110,11 @@ export default function Feeds() { (values) => { try { setUrl( - `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json` + `${FEEDS_BASE_URI}/${values.feeds_type}/${values.attack_type}/${values.prioritize}.json?ioc_type=${values.ioc_type}` ); initialValues.feeds_type = values.feeds_type; initialValues.attack_type = values.attack_type; + initialValues.ioc_type = values.ioc_type; initialValues.prioritize = values.prioritize; const resetPage = { @@ -148,7 +157,7 @@ export default function Feeds() { {(formik) => (
- +