Skip to content

Commit 7cbd77e

Browse files
authored
Merge pull request #13 from yeti-platform/find
Add functions to find() objects by name / type
2 parents b5e5143 + bcc045c commit 7cbd77e

File tree

5 files changed

+310
-3
lines changed

5 files changed

+310
-3
lines changed

.github/workflows/e2e.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: e2e tests
2+
3+
on: [pull_request]
4+
5+
jobs:
6+
e2e:
7+
runs-on: ubuntu-latest
8+
env:
9+
YETI_ENDPOINT: "http://localhost:80"
10+
strategy:
11+
matrix:
12+
os: [ubuntu-latest]
13+
python-version: ["3.10"]
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Install dependencies
18+
run: |
19+
sudo apt-get update
20+
sudo apt-get install -y python3-pip git
21+
sudo pip3 install poetry
22+
- name: Set up Python ${{ matrix.python-version }}
23+
uses: actions/setup-python@v4
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
cache: poetry
27+
28+
- name: Install a Yeti prod deployment
29+
run: |
30+
git clone https://github.com/yeti-platform/yeti-docker
31+
cd yeti-docker/prod
32+
./init.sh
33+
sleep 10
34+
- name: Create test Yeti user
35+
run: |
36+
cd yeti-docker/prod
37+
echo "YETI_API_KEY=$(docker compose run --rm api create-user test test --admin | awk -F'test:' '{print $2}')" >> $GITHUB_ENV
38+
- name: Install Python dependencies
39+
run: poetry install --no-root
40+
41+
- name: e2e testing
42+
run: |
43+
YETI_API_KEY=$YETI_API_KEY poetry run python -m unittest tests/e2e.py

.github/workflows/unittests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ jobs:
2323
run: poetry install --no-root
2424
- name: Test with unittest
2525
run: |
26-
poetry run python -m unittest discover -s tests/ -p '*.py'
26+
poetry run python -m unittest tests/api.py

tests/api.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,94 @@ def test_get_yara_bundle_with_overlays(self, mock_post):
332332
},
333333
)
334334

335+
@patch("yeti.api.requests.Session.get")
336+
def test_find_indicator(self, mock_get):
337+
mock_response = MagicMock()
338+
mock_response.content = b'{"id": "found_indicator"}'
339+
mock_get.return_value = mock_response
340+
341+
result = self.api.find_indicator(name="test_indicator", type="indicator.test")
342+
self.assertEqual(result, {"id": "found_indicator"})
343+
mock_get.assert_called_with(
344+
"http://fake-url/api/v2/indicators/?name=test_indicator&type=indicator.test",
345+
)
346+
347+
# Test 404 case
348+
mock_exception_with_status_code = requests.exceptions.HTTPError()
349+
mock_exception_with_status_code.response = MagicMock()
350+
mock_exception_with_status_code.response.status_code = 404
351+
mock_response.raise_for_status.side_effect = mock_exception_with_status_code
352+
mock_get.return_value = mock_response
353+
354+
result = self.api.find_indicator(name="not_found", type="indicator.test")
355+
self.assertIsNone(result)
356+
357+
@patch("yeti.api.requests.Session.get")
358+
def test_find_entity(self, mock_get):
359+
mock_response = MagicMock()
360+
mock_response.content = b'{"id": "found_entity"}'
361+
mock_get.return_value = mock_response
362+
363+
result = self.api.find_entity(name="test_entity", type="entity.test")
364+
self.assertEqual(result, {"id": "found_entity"})
365+
mock_get.assert_called_with(
366+
"http://fake-url/api/v2/entities/?name=test_entity&type=entity.test",
367+
)
368+
369+
# Test 404 case
370+
mock_exception_with_status_code = requests.exceptions.HTTPError()
371+
mock_exception_with_status_code.response = MagicMock()
372+
mock_exception_with_status_code.response.status_code = 404
373+
mock_response.raise_for_status.side_effect = mock_exception_with_status_code
374+
mock_get.return_value = mock_response
375+
376+
result = self.api.find_entity(name="not_found", type="entity.test")
377+
self.assertIsNone(result)
378+
379+
@patch("yeti.api.requests.Session.get")
380+
def test_find_observable(self, mock_get):
381+
mock_response = MagicMock()
382+
mock_response.content = b'{"id": "found_observable"}'
383+
mock_get.return_value = mock_response
384+
385+
result = self.api.find_observable(value="test_value", type="observable.test")
386+
self.assertEqual(result, {"id": "found_observable"})
387+
mock_get.assert_called_with(
388+
"http://fake-url/api/v2/observables/?value=test_value&type=observable.test",
389+
)
390+
391+
# Test 404 case
392+
mock_exception_with_status_code = requests.exceptions.HTTPError()
393+
mock_exception_with_status_code.response = MagicMock()
394+
mock_exception_with_status_code.response.status_code = 404
395+
mock_response.raise_for_status.side_effect = mock_exception_with_status_code
396+
mock_get.return_value = mock_response
397+
398+
result = self.api.find_observable(value="not_found", type="observable.test")
399+
self.assertIsNone(result)
400+
401+
@patch("yeti.api.requests.Session.get")
402+
def test_find_dfiq(self, mock_get):
403+
mock_response = MagicMock()
404+
mock_response.content = b'{"id": "found_dfiq"}'
405+
mock_get.return_value = mock_response
406+
407+
result = self.api.find_dfiq(name="test_dfiq", dfiq_type="scenario")
408+
self.assertEqual(result, {"id": "found_dfiq"})
409+
mock_get.assert_called_with(
410+
"http://fake-url/api/v2/dfiq/?name=test_dfiq&type=scenario",
411+
)
412+
413+
# Test 404 case
414+
mock_exception_with_status_code = requests.exceptions.HTTPError()
415+
mock_exception_with_status_code.response = MagicMock()
416+
mock_exception_with_status_code.response.status_code = 404
417+
mock_response.raise_for_status.side_effect = mock_exception_with_status_code
418+
mock_get.return_value = mock_response
419+
420+
result = self.api.find_dfiq(name="not_found", dfiq_type="scenario")
421+
self.assertIsNone(result)
422+
335423

336424
if __name__ == "__main__":
337425
unittest.main()

tests/e2e.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import os
2+
import time
3+
import unittest
4+
from unittest.mock import MagicMock, patch
5+
6+
import requests
7+
8+
from yeti import errors
9+
from yeti.api import YetiApi
10+
11+
12+
class YetiEndToEndTest(unittest.TestCase):
13+
def setUp(self):
14+
self.api = YetiApi(os.getenv("YETI_ENDPOINT"))
15+
16+
def test_auth_api_key(self):
17+
self.api.auth_api_key(os.getenv("YETI_API_KEY"))
18+
self.api.search_indicators(name="test")
19+
20+
def test_no_auth(self):
21+
with self.assertRaises(errors.YetiAuthError) as error:
22+
self.api.search_indicators(name="test")
23+
self.assertIn(
24+
"401 Client Error: Unauthorized for url: ",
25+
str(error.exception),
26+
)
27+
28+
def test_new_indicator(self):
29+
self.api.auth_api_key(os.getenv("YETI_API_KEY"))
30+
indicator = self.api.new_indicator(
31+
{
32+
"name": "test",
33+
"type": "regex",
34+
"description": "test",
35+
"pattern": "test[0-9]",
36+
"diamond": "victim",
37+
}
38+
)
39+
self.assertEqual(indicator["name"], "test")
40+
self.assertRegex(indicator["id"], r"[0-9]+")
41+
42+
def test_auth_refresh(self):
43+
self.api.auth_api_key(os.getenv("YETI_API_KEY"))
44+
self.api.search_indicators(name="test")
45+
46+
time.sleep(3)
47+
48+
self.api.search_indicators(name="test")
49+
50+
def test_search_indicators(self):
51+
self.api.auth_api_key(os.getenv("YETI_API_KEY"))
52+
self.api.auth_api_key(os.getenv("YETI_API_KEY"))
53+
self.api.new_indicator(
54+
{
55+
"name": "testSearch",
56+
"type": "regex",
57+
"description": "test",
58+
"pattern": "test[0-9]",
59+
"diamond": "victim",
60+
}
61+
)
62+
time.sleep(5)
63+
result = self.api.search_indicators(name="testSear")
64+
self.assertEqual(len(result), 1, result)
65+
self.assertEqual(result[0]["name"], "testSearch")
66+
67+
def test_find_indicator(self):
68+
self.api.auth_api_key(os.getenv("YETI_API_KEY"))
69+
self.api.auth_api_key(os.getenv("YETI_API_KEY"))
70+
self.api.new_indicator(
71+
{
72+
"name": "testGet",
73+
"type": "regex",
74+
"description": "test",
75+
"pattern": "test[0-9]",
76+
"diamond": "victim",
77+
}
78+
)
79+
time.sleep(5)
80+
indicator = self.api.find_indicator(name="testGet", type="regex")
81+
82+
self.assertEqual(indicator["name"], "testGet")
83+
self.assertEqual(indicator["pattern"], "test[0-9]")

yeti/api.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import urllib.parse
56
from typing import Any, Sequence
67

78
import requests
@@ -63,16 +64,18 @@ def do_request(
6364
body: bytes | None = None,
6465
headers: dict[str, Any] | None = None,
6566
retries: int = 3,
67+
params: dict[str, Any] | None = None,
6668
) -> bytes:
6769
"""Issues a request to the given URL.
6870
6971
Args:
7072
method: The HTTP method to use.
7173
url: The URL to issue the request to.
72-
json: The JSON payload to include in the request.
74+
json_data: The JSON payload to include in the request.
7375
body: The body to include in the request.
7476
headers: Extra headers to include in the request.
7577
retries: The number of times to retry the request.
78+
params: The query parameters to include in the request.
7679
7780
Returns:
7881
The response from the API; a bytes object.
@@ -90,6 +93,8 @@ def do_request(
9093
request_kwargs["json"] = json_data
9194
if body:
9295
request_kwargs["body"] = body
96+
if params:
97+
url = f"{url}?{urllib.parse.urlencode(params)}"
9398

9499
try:
95100
if method == "POST":
@@ -145,6 +150,28 @@ def refresh_auth(self):
145150
else:
146151
logger.warning("No auth function set, cannot refresh auth.")
147152

153+
def find_indicator(self, name: str, type: str) -> YetiObject | None:
154+
"""Finds an indicator in Yeti by name and type.
155+
156+
Args:
157+
name: The name of the indicator to find.
158+
type: The type of the indicator to find.
159+
160+
Returns:
161+
The response from the API; a dict representing the indicator.
162+
"""
163+
try:
164+
response = self.do_request(
165+
"GET",
166+
f"{self._url_root}/api/v2/indicators/",
167+
params={"name": name, "type": type},
168+
)
169+
except errors.YetiApiError as e:
170+
if e.status_code == 404:
171+
return None
172+
raise
173+
return json.loads(response)
174+
148175
def search_indicators(
149176
self,
150177
name: str | None = None,
@@ -163,7 +190,7 @@ def search_indicators(
163190
tags: The tags of the indicator to search for.
164191
165192
Returns:
166-
The response from the API; a dict representing the indicator.
193+
The response from the API; a list of dicts representing indicators.
167194
"""
168195

169196
if not any([name, indicator_type, pattern, tags]):
@@ -188,6 +215,28 @@ def search_indicators(
188215
)
189216
return json.loads(response)["indicators"]
190217

218+
def find_entity(self, name: str, type: str) -> YetiObject | None:
219+
"""Finds an entity in Yeti by name.
220+
221+
Args:
222+
name: The name of the entity to find.
223+
type: The type of the entity to find.
224+
225+
Returns:
226+
The response from the API; a dict representing the entity.
227+
"""
228+
try:
229+
response = self.do_request(
230+
"GET",
231+
f"{self._url_root}/api/v2/entities/",
232+
params={"name": name, "type": type},
233+
)
234+
except errors.YetiApiError as e:
235+
if e.status_code == 404:
236+
return None
237+
raise
238+
return json.loads(response)
239+
191240
def search_entities(self, name: str) -> list[YetiObject]:
192241
params = {"query": {"name": name}, "count": 0}
193242
response = self.do_request(
@@ -197,6 +246,28 @@ def search_entities(self, name: str) -> list[YetiObject]:
197246
)
198247
return json.loads(response)["entities"]
199248

249+
def find_observable(self, value: str, type: str) -> YetiObject | None:
250+
"""Finds an observable in Yeti by value and type.
251+
252+
Args:
253+
value: The value of the observable to find.
254+
type: The type of the observable to find.
255+
256+
Returns:
257+
The response from the API; a dict representing the observable.
258+
"""
259+
try:
260+
response = self.do_request(
261+
"GET",
262+
f"{self._url_root}/api/v2/observables/",
263+
params={"value": value, "type": type},
264+
)
265+
except errors.YetiApiError as e:
266+
if e.status_code == 404:
267+
return None
268+
raise
269+
return json.loads(response)
270+
200271
def search_observables(self, value: str) -> list[YetiObject]:
201272
"""Searches for an observable in Yeti.
202273
@@ -330,6 +401,28 @@ def get_yara_bundle_with_overlays(
330401

331402
return json.loads(result)
332403

404+
def find_dfiq(self, name: str, dfiq_type: str) -> YetiObject | None:
405+
"""Finds a DFIQ in Yeti by name and type.
406+
407+
Args:
408+
name: The name of the DFIQ to find.
409+
dfiq_type: The type of the DFIQ to find.
410+
411+
Returns:
412+
The response from the API; a dict representing the DFIQ object.
413+
"""
414+
try:
415+
response = self.do_request(
416+
"GET",
417+
f"{self._url_root}/api/v2/dfiq/",
418+
params={"name": name, "type": dfiq_type},
419+
)
420+
except errors.YetiApiError as e:
421+
if e.status_code == 404:
422+
return None
423+
raise
424+
return json.loads(response)
425+
333426
def search_dfiq(self, name: str, dfiq_type: str | None = None) -> list[YetiObject]:
334427
"""Searches for a DFIQ in Yeti.
335428

0 commit comments

Comments
 (0)