Skip to content

Commit f7aa814

Browse files
authored
Merge pull request #480 from intelowlproject/develop
1.6.0
2 parents c7e0c0f + b50bc3e commit f7aa814

10 files changed

+240
-7
lines changed

.env_template

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ COMPOSE_FILE=docker/default.yml:docker/local.override.yml
1313
#COMPOSE_FILE=docker/default.yml:docker/local.override.yml:docker/elasticsearch.yml
1414

1515
# If you want to run a specific version, populate this
16-
# REACT_APP_INTELOWL_VERSION="1.5.2"
16+
# REACT_APP_INTELOWL_VERSION="1.6.0"

api/urls.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear
22
# See the file 'LICENSE' for copying permission.
3-
from api.views import StatisticsViewSet, enrichment_view, feeds, feeds_advanced, feeds_pagination, general_honeypot_list
3+
from api.views import StatisticsViewSet, command_sequence_view, enrichment_view, feeds, feeds_advanced, feeds_pagination, general_honeypot_list
44
from django.urls import include, path
55
from rest_framework import routers
66

@@ -14,6 +14,7 @@
1414
path("feeds/advanced/", feeds_advanced),
1515
path("feeds/<str:feed_type>/<str:attack_type>/<str:prioritize>.<str:format_>", feeds),
1616
path("enrichment", enrichment_view),
17+
path("command_sequence", command_sequence_view),
1718
path("general_honeypot", general_honeypot_list),
1819
# router viewsets
1920
path("", include(router.urls)),

api/views/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from api.views.command_sequence import *
12
from api.views.enrichment import *
23
from api.views.feeds import *
34
from api.views.general_honeypot import *

api/views/command_sequence.py

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear
2+
# See the file 'LICENSE' for copying permission.
3+
import logging
4+
5+
from api.views.utils import is_ip_address, is_sha256hash
6+
from certego_saas.apps.auth.backend import CookieTokenAuthentication
7+
from django.http import Http404, HttpResponseBadRequest
8+
from greedybear.consts import FEEDS_LICENSE, GET
9+
from greedybear.models import IOC, CommandSequence, CowrieSession, Statistics, viewType
10+
from rest_framework import status
11+
from rest_framework.decorators import api_view, authentication_classes, permission_classes
12+
from rest_framework.permissions import IsAuthenticated
13+
from rest_framework.response import Response
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
@api_view([GET])
19+
@authentication_classes([CookieTokenAuthentication])
20+
@permission_classes([IsAuthenticated])
21+
def command_sequence_view(request):
22+
"""
23+
View function that handles command sequence queries based on IP addresses or SHA-256 hashes.
24+
25+
Retrieves and returns command sequences and related IOCs based on the query parameter.
26+
If IP address is given, returns all command sequences executed from this IP.
27+
If SHA-256 hash is given, returns details about the specific command sequence.
28+
Can include similar command sequences if requested.
29+
30+
Args:
31+
request: The HTTP request object containing query parameters
32+
query (str): The search term, can be either an IP address or a SHA-256 hash.
33+
include_similar (bool): When parameter is present, returns related command sequences based on clustering.
34+
35+
Returns:
36+
Response object with command sequence data or an error response
37+
38+
Raises:
39+
Http404: If the requested resource is not found
40+
"""
41+
observable = request.query_params.get("query")
42+
include_similar = request.query_params.get("include_similar") is not None
43+
logger.info(f"Command Sequence view requested by {request.user} for {observable}")
44+
source_ip = str(request.META["REMOTE_ADDR"])
45+
request_source = Statistics(source=source_ip, view=viewType.COMMAND_SEQUENCE_VIEW.value)
46+
request_source.save()
47+
48+
if not observable:
49+
return HttpResponseBadRequest("Missing required 'query' parameter")
50+
51+
if is_ip_address(observable):
52+
sessions = CowrieSession.objects.filter(source__name=observable, start_time__isnull=False, commands__isnull=False)
53+
sequences = set(s.commands for s in sessions)
54+
seqs = [
55+
{
56+
"time": s.start_time,
57+
"command_sequence": "\n".join(s.commands.commands),
58+
"command_sequence_hash": s.commands.commands_hash,
59+
}
60+
for s in sessions
61+
]
62+
related_iocs = IOC.objects.filter(cowriesession__commands__in=sequences).distinct().only("name")
63+
if include_similar:
64+
related_clusters = set(s.cluster for s in sequences if s.cluster is not None)
65+
related_iocs = IOC.objects.filter(cowriesession__commands__cluster__in=related_clusters).distinct().only("name")
66+
if not seqs:
67+
raise Http404(f"No command sequences found for IP: {observable}")
68+
data = {
69+
"license": FEEDS_LICENSE,
70+
"executed_commands": seqs,
71+
"executed_by": sorted([ioc.name for ioc in related_iocs]),
72+
}
73+
return Response(data, status=status.HTTP_200_OK)
74+
75+
if is_sha256hash(observable):
76+
try:
77+
seq = CommandSequence.objects.get(commands_hash=observable)
78+
seqs = CommandSequence.objects.filter(cluster=seq.cluster) if include_similar and seq.cluster is not None else [seq]
79+
commands = ["\n".join(seq.commands) for seq in seqs]
80+
sessions = CowrieSession.objects.filter(commands__in=seqs, start_time__isnull=False)
81+
iocs = [
82+
{
83+
"time": s.start_time,
84+
"ip": s.source.name,
85+
}
86+
for s in sessions
87+
]
88+
data = {
89+
"license": FEEDS_LICENSE,
90+
"commands": commands,
91+
"iocs": sorted(iocs, key=lambda d: d["time"], reverse=True),
92+
}
93+
return Response(data, status=status.HTTP_200_OK)
94+
except CommandSequence.DoesNotExist as exc:
95+
raise Http404(f"No command sequences found with hash: {observable}") from exc
96+
97+
return HttpResponseBadRequest("Query must be a valid IP address or SHA-256 hash")

api/views/utils.py

+39
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
# See the file 'LICENSE' for copying permission.
33
import csv
44
import logging
5+
import re
56
from datetime import datetime, timedelta
7+
from ipaddress import ip_address
68

79
from api.serializers import FeedsRequestSerializer, FeedsResponseSerializer
810
from django.contrib.postgres.aggregates import ArrayAgg
@@ -287,3 +289,40 @@ def feeds_response(iocs, feed_params, valid_feed_types, dict_only=False, verbose
287289
return Response(resp_data, status=status.HTTP_200_OK)
288290
case _:
289291
return HttpResponseBadRequest()
292+
293+
294+
def is_ip_address(string: str) -> bool:
295+
"""
296+
Validate if a string is a valid IP address (IPv4 or IPv6).
297+
298+
Uses the ipaddress module to perform validation. This function properly
299+
handles both IPv4 addresses and IPv6 addresses.
300+
301+
Args:
302+
string: The string to validate as an IP address
303+
304+
Returns:
305+
bool: True if the string is a valid IP address, False otherwise
306+
"""
307+
try:
308+
ip_address(string)
309+
except ValueError:
310+
return False
311+
return True
312+
313+
314+
def is_sha256hash(string: str) -> bool:
315+
"""
316+
Validate if a string is a valid SHA-256 hash.
317+
318+
A SHA-256 hash is a string of exactly 64 hexadecimal characters
319+
(0-9, a-f, A-F). This function checks if the input string matches
320+
this pattern using a regular expression.
321+
322+
Args:
323+
string: The string to validate as a SHA-256 hash
324+
325+
Returns:
326+
bool: True if the string is a valid SHA-256 hash, False otherwise
327+
"""
328+
return bool(re.fullmatch(r"^[A-Fa-f0-9]{64}$", string))

docker/.version

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
REACT_APP_GREEDYBEAR_VERSION="1.5.2"
1+
REACT_APP_GREEDYBEAR_VERSION="1.6.0"

greedybear/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
class viewType(models.TextChoices):
1010
FEEDS_VIEW = "feeds"
1111
ENRICHMENT_VIEW = "enrichment"
12+
COMMAND_SEQUENCE_VIEW = "command sequence"
1213

1314

1415
class iocType(models.TextChoices):

tests/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ def setUpTestData(cls):
4242
cls.ioc.save()
4343

4444
cls.cmd_seq = ["cd foo", "ls -la"]
45+
cls.hash = sha256("\n".join(cls.cmd_seq).encode()).hexdigest()
4546
cls.command_sequence = CommandSequence.objects.create(
4647
first_seen=cls.current_time,
4748
last_seen=cls.current_time,
4849
commands=cls.cmd_seq,
49-
commands_hash=sha256("\n".join(cls.cmd_seq).encode()).hexdigest(),
50+
commands_hash=cls.hash,
5051
cluster=11,
5152
)
5253
cls.command_sequence.save()

tests/test_models.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from hashlib import sha256
2-
31
from greedybear.models import Statistics, iocType, viewType
42

53
from . import CustomTestCase
@@ -34,7 +32,7 @@ def test_command_sequence_model(self):
3432
self.assertEqual(self.command_sequence.first_seen, self.current_time)
3533
self.assertEqual(self.command_sequence.last_seen, self.current_time)
3634
self.assertEqual(self.command_sequence.commands, self.cmd_seq)
37-
self.assertEqual(self.command_sequence.commands_hash, sha256("\n".join(self.cmd_seq).encode()).hexdigest())
35+
self.assertEqual(self.command_sequence.commands_hash, self.hash)
3836
self.assertEqual(self.command_sequence.cluster, 11)
3937

4038
def test_cowrie_session_model(self):

tests/test_views.py

+95
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from api.views.utils import is_ip_address, is_sha256hash
12
from greedybear.consts import FEEDS_LICENSE
23
from greedybear.models import GeneralHoneypot, Statistics, viewType
34
from rest_framework.test import APIClient
@@ -156,6 +157,7 @@ def setUpClass(self):
156157

157158
@classmethod
158159
def tearDownClass(self):
160+
super(StatisticsViewTestCase, self).tearDownClass()
159161
Statistics.objects.all().delete()
160162

161163
def test_200_feeds_sources(self):
@@ -209,3 +211,96 @@ def test_200_active_general_honeypots(self):
209211
response = self.client.get("/api/general_honeypot?onlyActive=true")
210212
self.assertEqual(response.status_code, 200)
211213
self.assertEqual(response.json(), ["Heralding", "Ciscoasa"])
214+
215+
216+
class CommandSequenceViewTestCase(CustomTestCase):
217+
"""Test cases for the command_sequence_view."""
218+
219+
def setUp(self):
220+
# setup client
221+
self.client = APIClient()
222+
self.client.force_authenticate(user=self.superuser)
223+
224+
def test_missing_query_parameter(self):
225+
"""Test that view returns BadRequest when query parameter is missing."""
226+
response = self.client.get("/api/command_sequence")
227+
self.assertEqual(response.status_code, 400)
228+
229+
def test_invalid_query_parameter(self):
230+
"""Test that view returns BadRequest when query parameter is invalid."""
231+
response = self.client.get("/api/command_sequence?query=invalid-input}")
232+
self.assertEqual(response.status_code, 400)
233+
234+
def test_ip_address_query(self):
235+
"""Test view with a valid IP address query."""
236+
response = self.client.get("/api/command_sequence?query=140.246.171.141")
237+
self.assertEqual(response.status_code, 200)
238+
self.assertIn("executed_commands", response.data)
239+
self.assertIn("executed_by", response.data)
240+
241+
def test_ip_address_query_with_similar(self):
242+
"""Test view with a valid IP address query including similar sequences."""
243+
response = self.client.get("/api/command_sequence?query=140.246.171.141&include_similar")
244+
self.assertEqual(response.status_code, 200)
245+
self.assertIn("executed_commands", response.data)
246+
self.assertIn("executed_by", response.data)
247+
248+
def test_nonexistent_ip_address(self):
249+
"""Test that view returns 404 for IP with no sequences."""
250+
response = self.client.get("/api/command_sequence?query=10.0.0.1")
251+
self.assertEqual(response.status_code, 404)
252+
253+
def test_hash_query(self):
254+
"""Test view with a valid hash query."""
255+
response = self.client.get(f"/api/command_sequence?query={self.hash}")
256+
self.assertEqual(response.status_code, 200)
257+
self.assertIn("commands", response.data)
258+
self.assertIn("iocs", response.data)
259+
260+
def test_hash_query_with_similar(self):
261+
"""Test view with a valid hash query including similar sequences."""
262+
response = self.client.get(f"/api/command_sequence?query={self.hash}&include_similar")
263+
self.assertEqual(response.status_code, 200)
264+
self.assertIn("commands", response.data)
265+
self.assertIn("iocs", response.data)
266+
267+
def test_nonexistent_hash(self):
268+
"""Test that view returns 404 for nonexistent hash."""
269+
response = self.client.get(f"/api/command_sequence?query={'f' * 64}")
270+
self.assertEqual(response.status_code, 404)
271+
272+
273+
class ValidationHelpersTestCase(CustomTestCase):
274+
"""Test cases for the validation helper functions."""
275+
276+
def test_is_ip_address_valid_ipv4(self):
277+
"""Test that is_ip_address returns True for valid IPv4 addresses."""
278+
self.assertTrue(is_ip_address("192.168.1.1"))
279+
self.assertTrue(is_ip_address("10.0.0.1"))
280+
self.assertTrue(is_ip_address("127.0.0.1"))
281+
282+
def test_is_ip_address_valid_ipv6(self):
283+
"""Test that is_ip_address returns True for valid IPv6 addresses."""
284+
self.assertTrue(is_ip_address("::1"))
285+
self.assertTrue(is_ip_address("2001:db8::1"))
286+
self.assertTrue(is_ip_address("fe80::1ff:fe23:4567:890a"))
287+
288+
def test_is_ip_address_invalid(self):
289+
"""Test that is_ip_address returns False for invalid IP addresses."""
290+
self.assertFalse(is_ip_address("not-an-ip"))
291+
self.assertFalse(is_ip_address("256.256.256.256"))
292+
self.assertFalse(is_ip_address("192.168.0"))
293+
self.assertFalse(is_ip_address("2001:xyz::1"))
294+
295+
def test_is_sha256hash_valid(self):
296+
"""Test that is_sha256hash returns True for valid SHA-256 hashes."""
297+
self.assertTrue(is_sha256hash("a" * 64))
298+
self.assertTrue(is_sha256hash("1234567890abcdef" * 4))
299+
self.assertTrue(is_sha256hash("A" * 64))
300+
301+
def test_is_sha256hash_invalid(self):
302+
"""Test that is_sha256hash returns False for invalid SHA-256 hashes."""
303+
self.assertFalse(is_sha256hash("a" * 63)) # Too short
304+
self.assertFalse(is_sha256hash("a" * 65)) # Too long
305+
self.assertFalse(is_sha256hash("z" * 64)) # Invalid chars
306+
self.assertFalse(is_sha256hash("not-a-hash"))

0 commit comments

Comments
 (0)