Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions api_app/analyzers_manager/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,16 @@ class DockerBasedAnalyzer(BaseAnalyzerMixin, metaclass=ABCMeta):
poll_distance: int
key_not_found_max_retries: int = 10

@staticmethod
def __get_response_json(resp: requests.Response) -> dict:
try:
data = resp.json()
except ValueError:
return {}
if isinstance(data, dict):
return data
return {}

@staticmethod
def __raise_in_case_bad_request(name, resp, params_to_check=None) -> bool:
"""
Expand All @@ -289,14 +299,15 @@ def __raise_in_case_bad_request(name, resp, params_to_check=None) -> bool:
# different error messages for different cases
if resp.status_code == 404:
raise AnalyzerConfigurationException(f"{name} docker container is not running.")
resp_json = DockerBasedAnalyzer.__get_response_json(resp)
if resp.status_code == 400:
err = resp.json().get("error", "")
err = resp_json.get("error") or resp.text or "Bad Request"
raise AnalyzerRunException(err)
if resp.status_code == 500:
raise AnalyzerRunException(f"Internal Server Error in {name} docker container")
# check to make sure there was a valid params in response
for param in params_to_check:
param_value = resp.json().get(param, None)
param_value = resp_json.get(param, None)
if not param_value:
raise AnalyzerRunException(
f"Unexpected Error. Please check log files under /var/log/intel_owl/{name.lower()}/"
Expand All @@ -308,14 +319,14 @@ def __raise_in_case_bad_request(name, resp, params_to_check=None) -> bool:

@staticmethod
def __query_for_result(url: str, key: str) -> Tuple[int, dict]:
headers = {"Accept": "application/json"}
headers = {"Accept": "application/json", "Host": "localhost"}
resp = requests.get(f"{url}?key={key}", headers=headers)
return resp.status_code, resp.json()
return resp.status_code, DockerBasedAnalyzer.__get_response_json(resp)

def __polling(self, req_key: str, chance: int, re_poll_try: int = 0):
try:
status_code, json_data = self.__query_for_result(self.url, req_key)
except (requests.RequestException, json.JSONDecodeError) as e:
except (requests.RequestException, ValueError) as e:
raise AnalyzerRunException(e)
if status_code == 404:
# This happens when they key does not exist.
Expand Down Expand Up @@ -392,23 +403,36 @@ def _docker_run(
try:
if req_files:
form_data = {"request_json": json.dumps(req_data)}
resp1 = requests.post(self.url, files=req_files, data=form_data)
normalized_files = {}
for field_name, value in req_files.items():
if isinstance(value, (bytes, bytearray)):
normalized_files[field_name] = (field_name, value)
else:
normalized_files[field_name] = value
resp1 = requests.post(
self.url,
files=normalized_files,
data=form_data,
headers={"Host": "localhost"},
)
else:
resp1 = requests.post(self.url, json=req_data)
resp1 = requests.post(self.url, json=req_data, headers={"Host": "localhost"})
except requests.exceptions.ConnectionError:
self._raise_container_not_running()

resp1_json = DockerBasedAnalyzer.__get_response_json(resp1)

# step #2: raise AnalyzerRunException in case of error
# Modified to support synchronous analyzers that return results directly in the initial response, avoiding unnecessary polling.
if avoid_polling:
report = resp1.json().get("report", None)
err = resp1.json().get("error", None)
report = resp1_json.get("report", None)
err = resp1_json.get("error", None)
else:
if not self.__raise_in_case_bad_request(self.name, resp1):
raise AssertionError

# step #3: if no error, continue and try to fetch result
key = resp1.json().get("key")
key = resp1_json.get("key")
final_resp = self.__poll_for_result(key)
err = final_resp.get("error", None)
report = final_resp.get("report", None)
Expand Down Expand Up @@ -440,7 +464,7 @@ def _docker_get(self):

# step #1: request new analysis
try:
resp = requests.get(url=self.url)
resp = requests.get(url=self.url, headers={"Host": "localhost"})
except requests.exceptions.ConnectionError:
self._raise_container_not_running()

Expand All @@ -454,7 +478,7 @@ def health_check(self, user: User = None) -> bool:
basic health check: if instance is up or not (timeout - 10s)
"""
try:
requests.head(self.url, timeout=10)
requests.head(self.url, timeout=10, headers={"Host": "localhost"})
except requests.exceptions.RequestException:
health_status = False
else:
Expand Down
3 changes: 3 additions & 0 deletions api_app/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ class ApiAppConfig(AppConfig):
name = "api_app"

def ready(self): # skipcq: PYL-R0201
from api_app.helpers import patch_requests_default_timeout

patch_requests_default_timeout()
from . import signals # noqa
37 changes: 37 additions & 0 deletions api_app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import random
import re
import warnings
from functools import wraps
from os import environ

from django.utils import timezone

Expand Down Expand Up @@ -84,3 +86,38 @@ def wrapper(*args, **kwargs):
return wrapper

return decorator


def get_default_requests_timeout() -> tuple[float, float]:
connect_timeout_default = 10.0
read_timeout_default = 30.0

try:
connect_timeout = float(environ.get("INTELOWL_REQUESTS_CONNECT_TIMEOUT", connect_timeout_default))
except (TypeError, ValueError):
connect_timeout = connect_timeout_default

try:
read_timeout = float(environ.get("INTELOWL_REQUESTS_READ_TIMEOUT", read_timeout_default))
except (TypeError, ValueError):
read_timeout = read_timeout_default

return connect_timeout, read_timeout


def patch_requests_default_timeout() -> None:
import requests

api_request = requests.api.request
if getattr(api_request, "_intelowl_default_timeout_patched", False):
return

@wraps(api_request)
def wrapped(method, url, **kwargs):
if "timeout" not in kwargs or kwargs["timeout"] is None:
kwargs["timeout"] = get_default_requests_timeout()
return wrapped._intelowl_original(method, url, **kwargs)

wrapped._intelowl_default_timeout_patched = True
wrapped._intelowl_original = api_request
requests.api.request = wrapped
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl
# See the file 'LICENSE' for copying permission.

from unittest.mock import patch

from django.test import SimpleTestCase
from requests import Response

from api_app.analyzers_manager.classes import DockerBasedAnalyzer
from api_app.analyzers_manager.exceptions import AnalyzerRunException


class DockerBasedAnalyzerHttpErrorsTests(SimpleTestCase):
def test_bad_request_with_non_json_body_raises_analyzer_run_exception(self):
resp = Response()
resp.status_code = 400
resp._content = b""

with self.assertRaises(AnalyzerRunException):
DockerBasedAnalyzer._DockerBasedAnalyzer__raise_in_case_bad_request(
"StringsInfo",
resp,
)

def test_docker_run_normalizes_bytes_files_to_requests_file_tuples(self):
captured = {}

class FakeResponse:
status_code = 200
text = ""

def json(self):
return {"report": {"ok": True}}

def stub_post(url, files=None, data=None, headers=None, **kwargs):
captured["files"] = files
captured["data"] = data
captured["headers"] = headers
return FakeResponse()

class Dummy:
url = "http://example.com/analyze"
name = "Dummy"

def __repr__(self):
return "<Dummy>"

def _raise_container_not_running(self):
raise AssertionError

with patch("api_app.analyzers_manager.classes.requests.post", side_effect=stub_post):
result = DockerBasedAnalyzer._docker_run(
Dummy(),
req_data={"args": []},
req_files={"sample.bin": b"123"},
avoid_polling=True,
)

self.assertEqual(result, {"ok": True})
self.assertEqual(captured["files"]["sample.bin"], ("sample.bin", b"123"))
self.assertEqual(captured["headers"]["Host"], "localhost")
79 changes: 79 additions & 0 deletions tests/api_app/test_requests_timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl
# See the file 'LICENSE' for copying permission.

import os

import requests
from django.test import SimpleTestCase

from api_app.helpers import get_default_requests_timeout, patch_requests_default_timeout


class RequestsDefaultTimeoutPatchTests(SimpleTestCase):
def setUp(self):
patch_requests_default_timeout()

def test_injects_default_timeout_when_missing(self):
api_request = requests.api.request
original = api_request._intelowl_original
captured = {}

def stub(method, url, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return "ok"

api_request._intelowl_original = stub
try:
result = requests.get("http://example.com")
self.assertEqual(result, "ok")
self.assertEqual(captured["timeout"], get_default_requests_timeout())
finally:
api_request._intelowl_original = original

def test_keeps_explicit_timeout(self):
api_request = requests.api.request
original = api_request._intelowl_original
captured = {}

def stub(method, url, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return "ok"

api_request._intelowl_original = stub
try:
result = requests.get("http://example.com", timeout=5)
self.assertEqual(result, "ok")
self.assertEqual(captured["timeout"], 5)
finally:
api_request._intelowl_original = original

def test_timeout_can_be_configured_via_env(self):
old_connect = os.environ.get("INTELOWL_REQUESTS_CONNECT_TIMEOUT")
old_read = os.environ.get("INTELOWL_REQUESTS_READ_TIMEOUT")
os.environ["INTELOWL_REQUESTS_CONNECT_TIMEOUT"] = "1.5"
os.environ["INTELOWL_REQUESTS_READ_TIMEOUT"] = "2.5"

try:
api_request = requests.api.request
original = api_request._intelowl_original
captured = {}

def stub(method, url, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return "ok"

api_request._intelowl_original = stub
try:
requests.get("http://example.com")
self.assertEqual(captured["timeout"], (1.5, 2.5))
finally:
api_request._intelowl_original = original
finally:
if old_connect is None:
os.environ.pop("INTELOWL_REQUESTS_CONNECT_TIMEOUT", None)
else:
os.environ["INTELOWL_REQUESTS_CONNECT_TIMEOUT"] = old_connect
if old_read is None:
os.environ.pop("INTELOWL_REQUESTS_READ_TIMEOUT", None)
else:
os.environ["INTELOWL_REQUESTS_READ_TIMEOUT"] = old_read
Loading