Current File : //opt/imunify360/venv/lib64/python3.11/site-packages/imav/malwarelib/utils/malware_response.py |
"""
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Copyright © 2019 Cloud Linux Software Inc.
This software is also available under ImunifyAV commercial license,
see <https://www.imunify360.com/legal/eula>
Utilities to help upload a malicious file."""
import asyncio
import json
import logging
import os
from dataclasses import dataclass
from typing import Iterable, List
from urllib.parse import quote_from_bytes, urljoin
import urllib
from defence360agent import utils
from defence360agent.contracts.config import Core, Malware as Config
from defence360agent.contracts.license import LicenseCLN, LicenseError
from defence360agent.internals.iaid import (
IAIDTokenError,
IndependentAgentIDAPI,
)
from imav.contracts.config import MalwareTune
logger = logging.getLogger(__name__)
if utils.OsReleaseInfo.id_like() & utils.OsReleaseInfo.DEBIAN:
_CURL = "/opt/alt/curlssl/usr/bin/curl"
else:
_CURL = "/opt/alt/curlssl11/usr/bin/curl"
_API_BASE_URL = os.environ.get("I360_MRS_API_BASE_URL", Core.API_BASE_URL)
_ENDPOINT_UPLOAD = os.environ.get("I360_MRS_ENDPOINT_UPLOAD", "api/v1/upload")
_ENDPOINT_CHECK = os.environ.get(
"I360_MRS_ENDPOINT_CHECK", "api/v1/check-known-hashes"
)
_POST_FILE_TIMEOUT = int(
os.environ.get("IMUNIFY360_POST_FILE_TIMEOUT", 60 * 60) # hour
)
FALSE_NEGATIVE = "false_negative"
FALSE_POSITIVE = "false_positive"
UNKNOWN_REASON = "unknown"
class ClientError(OSError):
"""HTTP client error.
It is used to hide what specific http client is used by upload_file().
"""
class FileTooLargeError(OSError):
pass
def _token_to_headers():
token = LicenseCLN.get_token()
headers = {
"I360-Id": token["id"],
"I360-Limit": token["limit"],
"I360-Status": token["status"],
"I360-Token-Expire-Utc": token["token_expire_utc"],
"I360-Token-Created-Utc": token["token_created_utc"],
"I360-Sign": token["sign"],
}
headers = {key: str(value) for key, value in headers.items()}
return headers
async def _post_file(filename, url, headers=None, timeout=None):
"""
Post *filename* as multipart/form-data to *url* with given HTTP *headers*.
Return server response as bytes (http body).
Raise TimeoutError on timeout.
Raise ConnectionError if failed to connect to host.
Raise ClientError on error.
"""
if headers is None:
headers = {}
headers_args = [
b"-H%s: %s" % (header.encode("ascii"), value.encode("latin-1"))
for header, value in headers.items()
]
quoted_full_path = quote_from_bytes(os.fsencode(filename), safe="").encode(
"ascii"
)
cmd = (
[os.fsencode(_CURL)]
+ headers_args
+ [b"--max-time", str(timeout).encode("ascii")] * (timeout is not None)
+ [
b"--form",
# https://curl.haxx.se/docs/knownbugs.html#multipart_formposts_file_name_en
b'file=@"%s";filename="%s"'
% (
# escape backslash, double quotes
os.fsencode(filename)
.replace(b"\\", b"\\\\")
.replace(b'"', b'\\"'),
quoted_full_path,
),
b"--fail",
# disable progress meter
b"--silent",
b"--show-error",
url.encode("ascii"),
]
)
rc, out, err = await utils.run(cmd)
if rc != 0:
Error = (
ConnectionError
if rc == 7
else TimeoutError
if rc == 28
else ClientError
)
raise Error(
(
"Failed to post {filename} to {url}:"
" curl: cmd={cmd}, rc={rc}, out={out}, err={err}"
).format(**vars())
)
return out
async def upload_file(file: str, upload_reason=UNKNOWN_REASON):
"""
Upload a file to Malware Response Service.
:param file: path to file
:param upload_reason: one of 'unknown', 'false_positive', 'false_negative'
:return: dict representing json response
:raises LicenseError:
"""
if not LicenseCLN.is_valid():
raise LicenseError(
"File uploading to Malware Responce Serivce "
"requires a valid license"
)
file_size = os.path.getsize(file)
if file_size > Config.MAX_MRS_UPLOAD_FILE:
raise FileTooLargeError(
"File {} is {} bytes, files larger than {} bytes "
"are not allowed.".format(
file, file_size, Config.MAX_MRS_UPLOAD_FILE
)
)
url = urljoin(_API_BASE_URL, _ENDPOINT_UPLOAD)
headers = {
**_token_to_headers(),
"I360-Upload-Reason": upload_reason,
}
response_body = await _post_file(
file, url, headers, timeout=_POST_FILE_TIMEOUT
)
result = json.loads(response_body.decode())
logger.info(
"Uploaded file %r to the Malware Response Service with reason: %s",
file,
upload_reason,
)
return result
async def upload_with_retries(file, upload_reason=UNKNOWN_REASON):
"""
:raises LicenseError,
ClientError,
TimeoutError,
ConnectionError,
"""
# exponential backoff: total delay ~6 min (not including the upload time)
delays = [0.5, 2.5, 6, 15, 40, 100, 200]
max_tries = len(delays) + 1
for i, pause in enumerate(delays, start=1):
error = await _try_upload(
file, raise_errors=False, upload_reason=upload_reason
)
if not error:
break
if isinstance(error, FileTooLargeError):
logger.warning("File %s is too big. Stop retrying to upload", file)
break
logger.warning(
"Attempt %d/%d: failed uploading file %s, reason: %s. Retrying in"
" %s seconds",
i,
max_tries,
file,
error,
pause,
)
await asyncio.sleep(pause)
else: # exhausted retries, one last attempt, raise error if it fails
await _try_upload(file, raise_errors=True, upload_reason=upload_reason)
async def _try_upload(file, raise_errors, *, upload_reason=UNKNOWN_REASON):
"""Return error instead of raising it unless *raise_errors* is true.
:raises LicenseError:
:raises ClientError,
TimeoutError,
ConnectionError,
FileTooLargeError: if raise_errors is True
"""
try:
await upload_file(file, upload_reason=upload_reason)
except (
ClientError,
ConnectionError,
FileTooLargeError,
TimeoutError,
) as e:
if raise_errors:
raise e
return e
else:
return None
@dataclass
class HitInfo:
file: str
hash: str
async def check_known_hashes(
loop, hashes: Iterable[str], upload_reason=UNKNOWN_REASON
) -> List[str]:
hashes = list(hashes)
if MalwareTune.NO_CHECK_KNOWN_HASHES:
return hashes
try:
token = await IndependentAgentIDAPI.get_token()
except IAIDTokenError:
return hashes
url = urljoin(_API_BASE_URL, _ENDPOINT_CHECK)
headers = {
"X-Auth": token,
"I360-Upload-Reason": upload_reason,
"Content-Type": "application/json",
}
request = {"hashes": hashes}
try:
result = await loop.run_in_executor(
None,
_do_request,
urllib.request.Request(
url,
data=json.dumps(request).encode(),
headers=headers,
method="POST",
),
)
except Exception as e:
logger.warning("Failed to check known hashes: %s", e)
return hashes
return result["unknown_hashes"]
def _do_request(request: urllib.request.Request) -> None:
with urllib.request.urlopen(
request, timeout=Core.DEFAULT_SOCKET_TIMEOUT
) as response:
if response.status != 200:
raise Exception("status code is {}".format(response.status))
return json.loads(response.read().decode())