mirror of https://github.com/MISP/misp-modules
Modules for expansion services, import and export in MISP
http://misp.github.io/misp-modules
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
841 lines
29 KiB
841 lines
29 KiB
""" |
|
Lastline Community API Client and Utilities. |
|
|
|
:Copyright: |
|
Copyright 2019 Lastline, Inc. All Rights Reserved. |
|
|
|
Licensed under the Apache License, Version 2.0 (the "License"); |
|
you may not use this file except in compliance with the License. |
|
You may obtain a copy of the License at |
|
|
|
http://www.apache.org/licenses/LICENSE-2.0 |
|
|
|
Unless required by applicable law or agreed to in writing, software |
|
distributed under the License is distributed on an "AS IS" BASIS, |
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
See the License for the specific language governing permissions and |
|
limitations under the License. |
|
|
|
Copyright (c) 2010-2012 by Internet Systems Consortium, Inc. ("ISC") |
|
|
|
Permission to use, copy, modify, and/or distribute this software for any |
|
purpose with or without fee is hereby granted, provided that the above |
|
copyright notice and this permission notice appear in all copies. |
|
|
|
THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES |
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR |
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT |
|
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
|
""" |
|
import abc |
|
import logging |
|
import io |
|
import ipaddress |
|
import pymisp |
|
import re |
|
import requests |
|
from urllib import parse |
|
|
|
|
|
DEFAULT_LL_PORTAL_API_URL = "https://user.lastline.com/papi" |
|
|
|
DEFAULT_LL_ANALYSIS_API_URL = "https://analysis.lastline.com" |
|
|
|
LL_HOSTED_DOMAINS = frozenset([ |
|
"user.lastline.com", |
|
"user.emea.lastline.com", |
|
]) |
|
|
|
|
|
def purge_none(d): |
|
"""Purge None entries from a dictionary.""" |
|
return {k: v for k, v in d.items() if v is not None} |
|
|
|
|
|
def get_task_link(uuid, analysis_url=None, portal_url=None): |
|
""" |
|
Get the task link given the task uuid and at least one API url. |
|
|
|
:param str uuid: the task uuid |
|
:param str|None analysis_url: the URL to the analysis API endpoint |
|
:param str|None portal_url: the URL to the portal API endpoint |
|
:rtype: str |
|
:return: the task link |
|
:raises ValueError: if not enough parameters have been provided |
|
""" |
|
if not analysis_url and not portal_url: |
|
raise ValueError("Neither analysis URL or portal URL have been specified") |
|
if analysis_url: |
|
portal_url = "{}/papi".format(analysis_url.replace("analysis.", "user.")) |
|
portal_url_path = "../portal#/analyst/task/{}/overview".format(uuid) |
|
return parse.urljoin(portal_url, portal_url_path) |
|
|
|
|
|
def get_portal_url_from_task_link(task_link): |
|
""" |
|
Return the portal API url related to the provided task link. |
|
|
|
:param str task_link: a link |
|
:rtype: str |
|
:return: the portal API url |
|
""" |
|
parsed_uri = parse.urlparse(task_link) |
|
return "{uri.scheme}://{uri.netloc}/papi".format(uri=parsed_uri) |
|
|
|
|
|
def get_uuid_from_task_link(task_link): |
|
""" |
|
Return the uuid from a task link. |
|
|
|
:param str task_link: a link |
|
:rtype: str |
|
:return: the uuid |
|
:raises ValueError: if the link contains not task uuid |
|
""" |
|
try: |
|
return re.findall("[a-fA-F0-9]{32}", task_link)[0] |
|
except IndexError: |
|
raise ValueError("Link does not contain a valid task uuid") |
|
|
|
|
|
def is_task_hosted(task_link): |
|
""" |
|
Return whether the portal link is pointing to a hosted submission. |
|
|
|
:param str task_link: a link |
|
:rtype: boolean |
|
:return: whether the link points to a hosted analysis |
|
""" |
|
for domain in LL_HOSTED_DOMAINS: |
|
if domain in task_link: |
|
return True |
|
return False |
|
|
|
|
|
class InvalidArgument(Exception): |
|
"""Error raised invalid.""" |
|
|
|
|
|
class CommunicationError(Exception): |
|
"""Exception raised in case of timeouts or other network problem.""" |
|
|
|
|
|
class Error(Exception): |
|
"""Generic server error.""" |
|
|
|
|
|
class ApiError(Error): |
|
"""Server error with a message and an error code.""" |
|
def __init__(self, error_msg, error_code=None): |
|
super(ApiError, self).__init__(error_msg, error_code) |
|
self.error_msg = error_msg |
|
self.error_code = error_code |
|
|
|
def __str__(self): |
|
if self.error_code is None: |
|
error_code = "" |
|
else: |
|
error_code = " ({})".format(self.error_code) |
|
return "{}{}".format(self.error_msg, error_code) |
|
|
|
|
|
class LastlineAbstractClient(abc.ABC): |
|
""""A very basic HTTP client providing basic functionality.""" |
|
|
|
__metaclass__ = abc.ABCMeta |
|
|
|
SUB_APIS = ('analysis', 'authentication', 'knowledgebase', 'login') |
|
FORMATS = ["json", "xml"] |
|
|
|
@classmethod |
|
def sanitize_login_params(cls, api_key, api_token, username, password): |
|
""" |
|
Return a dictionary with either API or USER credentials. |
|
|
|
:param str|None api_key: the API key |
|
:param str|None api_token: the API token |
|
:param str|None username: the username |
|
:param str|None password: the password |
|
:rtype: dict[str, str] |
|
:return: the dictionary |
|
:raises InvalidArgument: if too many values are invalid |
|
""" |
|
if api_key and api_token: |
|
return { |
|
"key": api_key, |
|
"api_token": api_token, |
|
} |
|
elif username and password: |
|
return { |
|
"username": username, |
|
"password": password, |
|
} |
|
else: |
|
raise InvalidArgument("Arguments provided do not contain valid data") |
|
|
|
@classmethod |
|
def get_login_params_from_dict(cls, d): |
|
""" |
|
Get the module configuration from a ConfigParser object. |
|
|
|
:param dict[str, str] d: the dictionary |
|
:rtype: dict[str, str] |
|
:return: the parsed configuration |
|
""" |
|
api_key = d.get("key") |
|
api_token = d.get("api_token") |
|
username = d.get("username") |
|
password = d.get("password") |
|
return cls.sanitize_login_params(api_key, api_token, username, password) |
|
|
|
@classmethod |
|
def get_login_params_from_conf(cls, conf, section_name): |
|
""" |
|
Get the module configuration from a ConfigParser object. |
|
|
|
:param ConfigParser conf: the conf object |
|
:param str section_name: the section name |
|
:rtype: dict[str, str] |
|
:return: the parsed configuration |
|
""" |
|
api_key = conf.get(section_name, "key", fallback=None) |
|
api_token = conf.get(section_name, "api_token", fallback=None) |
|
username = conf.get(section_name, "username", fallback=None) |
|
password = conf.get(section_name, "password", fallback=None) |
|
return cls.sanitize_login_params(api_key, api_token, username, password) |
|
|
|
@classmethod |
|
def load_from_conf(cls, conf, section_name): |
|
""" |
|
Load client from a ConfigParser object. |
|
|
|
:param ConfigParser conf: the conf object |
|
:param str section_name: the section name |
|
:rtype: T <- LastlineAbstractClient |
|
:return: the loaded client |
|
""" |
|
url = conf.get(section_name, "url") |
|
return cls(url, cls.get_login_params_from_conf(conf, section_name)) |
|
|
|
def __init__(self, api_url, login_params, timeout=60, verify_ssl=True): |
|
""" |
|
Instantiate a Lastline mini client. |
|
|
|
:param str api_url: the URL of the API |
|
:param dict[str, str]: the login parameters |
|
:param int timeout: the timeout |
|
:param boolean verify_ssl: whether to verify the SSL certificate |
|
""" |
|
self._url = api_url |
|
self._login_params = login_params |
|
self._timeout = timeout |
|
self._verify_ssl = verify_ssl |
|
self._session = None |
|
self._logger = logging.getLogger(__name__) |
|
|
|
@abc.abstractmethod |
|
def _login(self): |
|
"""Login using account-based or key-based methods.""" |
|
|
|
def _is_logged_in(self): |
|
"""Return whether we have an active session.""" |
|
return self._session is not None |
|
|
|
@staticmethod |
|
def _parse_response(response): |
|
""" |
|
Parse the response. |
|
|
|
:param requests.Response response: the response |
|
:rtype: tuple(str|None, Error|ApiError) |
|
:return: a tuple with mutually exclusive fields (either the response or the error) |
|
""" |
|
try: |
|
ret = response.json() |
|
if "success" not in ret: |
|
return None, Error("no success field in response") |
|
|
|
if not ret["success"]: |
|
error_msg = ret.get("error", "") |
|
error_code = ret.get("error_code", None) |
|
return None, ApiError(error_msg, error_code) |
|
|
|
if "data" not in ret: |
|
return None, Error("no data field in response") |
|
|
|
return ret["data"], None |
|
except ValueError as e: |
|
return None, Error("Response not json {}".format(e)) |
|
|
|
def _handle_response(self, response, raw=False): |
|
""" |
|
Check a response for issues and parse the return. |
|
|
|
:param requests.Response response: the response |
|
:param boolean raw: whether the raw body should be returned |
|
:rtype: str |
|
:return: if raw, return the response content; if not raw, the data field |
|
:raises: CommunicationError, ApiError, Error |
|
""" |
|
# Check for HTTP errors, and re-raise in case |
|
try: |
|
response.raise_for_status() |
|
except requests.RequestException as e: |
|
_, err = self._parse_response(response) |
|
if isinstance(err, ApiError): |
|
err_msg = "{}: {}".format(e, err.error_msg) |
|
else: |
|
err_msg = "{}".format(e) |
|
raise CommunicationError(err_msg) |
|
|
|
# Otherwise return the data (either parsed or not) but reraise if we have an API error |
|
if raw: |
|
return response.content |
|
data, err = self._parse_response(response) |
|
if err: |
|
raise err |
|
return data |
|
|
|
def _build_url(self, sub_api, parts, requested_format="json"): |
|
if sub_api not in self.SUB_APIS: |
|
raise InvalidArgument(sub_api) |
|
if requested_format not in self.FORMATS: |
|
raise InvalidArgument(requested_format) |
|
num_parts = 2 + len(parts) |
|
pattern = "/".join(["%s"] * num_parts) + ".%s" |
|
params = [self._url, sub_api] + parts + [requested_format] |
|
return pattern % tuple(params) |
|
|
|
def post(self, module, function, params=None, data=None, files=None, fmt="json"): |
|
if isinstance(function, list): |
|
functions = function |
|
else: |
|
functions = [function] if function else [] |
|
url = self._build_url(module, functions, requested_format=fmt) |
|
return self.do_request( |
|
url=url, |
|
method="POST", |
|
params=params, |
|
data=data, |
|
files=files, |
|
fmt=fmt, |
|
) |
|
|
|
def get(self, module, function, params=None, fmt="json"): |
|
if isinstance(function, list): |
|
functions = function |
|
else: |
|
functions = [function] if function else [] |
|
url = self._build_url(module, functions, requested_format=fmt) |
|
return self.do_request( |
|
url=url, |
|
method="GET", |
|
params=params, |
|
fmt=fmt, |
|
) |
|
|
|
def do_request( |
|
self, |
|
method, |
|
url, |
|
params=None, |
|
data=None, |
|
files=None, |
|
fmt="json", |
|
raw=False, |
|
raw_response=False, |
|
headers=None, |
|
stream_response=False |
|
): |
|
if raw_response: |
|
raw = True |
|
|
|
if fmt: |
|
fmt = fmt.lower().strip() |
|
if fmt not in self.FORMATS: |
|
raise InvalidArgument("Only json, xml, html and pdf supported") |
|
elif not raw: |
|
raise InvalidArgument("Unformatted response requires raw=True") |
|
|
|
if fmt != "json" and not raw: |
|
raise InvalidArgument("Non-json format requires raw=True") |
|
|
|
if method not in ["POST", "GET"]: |
|
raise InvalidArgument("Only POST and GET supported") |
|
|
|
if not self._is_logged_in(): |
|
self._login() |
|
|
|
try: |
|
try: |
|
response = self._session.request( |
|
method=method, |
|
url=url, |
|
data=data, |
|
params=params, |
|
files=files, |
|
verify=self._verify_ssl, |
|
timeout=self._timeout, |
|
stream=stream_response, |
|
headers=headers, |
|
) |
|
except requests.RequestException as e: |
|
raise CommunicationError(e) |
|
|
|
if raw_response: |
|
return response |
|
return self._handle_response(response, raw) |
|
|
|
except Error as e: |
|
raise e |
|
|
|
except CommunicationError as e: |
|
raise e |
|
|
|
|
|
class AnalysisClient(LastlineAbstractClient): |
|
|
|
def _login(self): |
|
""" |
|
Creates auth session for malscape-service. |
|
|
|
Credentials are 'key' and 'api_token'. |
|
""" |
|
if self._session is None: |
|
self._session = requests.session() |
|
url = self._build_url("authentication", ["login"]) |
|
self.do_request("POST", url, params=purge_none(self._login_params)) |
|
|
|
def get_progress(self, uuid): |
|
""" |
|
Get the completion progress of a given task. |
|
:param str uuid: the unique identifier of the submitted task |
|
:rtype: dict[str, int] |
|
:return: a dictionary like the the following: |
|
{ |
|
"completed": 1, |
|
"progress": 100 |
|
} |
|
""" |
|
url = self._build_url('analysis', ['get_progress']) |
|
params = {'uuid': uuid} |
|
return self.do_request("POST", url, params=params) |
|
|
|
def get_result(self, uuid): |
|
""" |
|
Get report results for a given task. |
|
|
|
:param str uuid: the unique identifier of the submitted task |
|
:rtype: dict[str, any] |
|
:return: a dictionary like the the following: |
|
{ |
|
"completed": 1, |
|
"progress": 100 |
|
} |
|
""" |
|
# better: use 'get_results()' but that would break |
|
# backwards-compatibility |
|
url = self._build_url('analysis', ['get']) |
|
params = {'uuid': uuid} |
|
return self.do_request("GET", url, params=params) |
|
|
|
def submit_file( |
|
self, |
|
file_data, |
|
file_name=None, |
|
password=None, |
|
analysis_env=None, |
|
allow_network_traffic=True, |
|
analysis_timeout=None, |
|
bypass_cache=False, |
|
): |
|
""" |
|
Upload a file to be analyzed. |
|
|
|
:param bytes file_data: the data as a byte sequence |
|
:param str|None file_name: if set, represents the name of the file to submit |
|
:param str|None password: if set, use it to extract the sample |
|
:param str|None analysis_env: if set, e.g windowsxp |
|
:param boolean allow_network_traffic: if set to False, deny network connections |
|
:param int|None analysis_timeout: if set limit the duration of the analysis |
|
:param boolean bypass_cache: whether to re-process a file (requires special permissions) |
|
:rtype: dict[str, any] |
|
:return: a dictionary in the following form if the analysis is already available: |
|
{ |
|
"submission": "2019-11-17 09:33:23", |
|
"child_tasks": [...], |
|
"reports": [...], |
|
"submission_timestamp": "2019-11-18 16:11:04", |
|
"task_uuid": "86097fb8e4cd00100464cb001b97ecbe", |
|
"score": 0, |
|
"analysis_subject": { |
|
"url": "https://www.google.com" |
|
}, |
|
"last_submission_timestamp": "2019-11-18 16:11:04" |
|
} |
|
|
|
OR the following if the analysis is still pending: |
|
|
|
{ |
|
"submission_timestamp": "2019-11-18 13:59:25", |
|
"task_uuid": "f3c0ae115d51001017ff8da768fa6049", |
|
} |
|
""" |
|
file_stream = io.BytesIO(file_data) |
|
api_url = self._build_url("analysis", ["submit", "file"]) |
|
params = purge_none({ |
|
"bypass_cache": bypass_cache and 1 or None, |
|
"analysis_timeout": analysis_timeout, |
|
"analysis_env": analysis_env, |
|
"allow_network_traffic": allow_network_traffic and 1 or None, |
|
"filename": file_name, |
|
"password": password, |
|
"full_report_score": -1, |
|
}) |
|
|
|
files = purge_none({ |
|
# If an explicit filename was provided, we can pass it down to |
|
# python-requests to use it in the multipart/form-data. This avoids |
|
# having python-requests trying to guess the filename based on stream |
|
# attributes. |
|
# |
|
# The problem with this is that, if the filename is not ASCII, then |
|
# this triggers a bug in flask/werkzeug which means the file is |
|
# thrown away. Thus, we just force an ASCII name |
|
"file": ('dummy-ascii-name-for-file-param', file_stream), |
|
}) |
|
|
|
return self.do_request("POST", api_url, params=params, files=files) |
|
|
|
def submit_url( |
|
self, |
|
url, |
|
referer=None, |
|
user_agent=None, |
|
bypass_cache=False, |
|
): |
|
""" |
|
Upload an URL to be analyzed. |
|
|
|
:param str url: the url to analyze |
|
:param str|None referer: the referer |
|
:param str|None user_agent: the user agent |
|
:param boolean bypass_cache: bypass_cache |
|
:rtype: dict[str, any] |
|
:return: a dictionary like the following if the analysis is already available: |
|
{ |
|
"submission": "2019-11-17 09:33:23", |
|
"child_tasks": [...], |
|
"reports": [...], |
|
"submission_timestamp": "2019-11-18 16:11:04", |
|
"task_uuid": "86097fb8e4cd00100464cb001b97ecbe", |
|
"score": 0, |
|
"analysis_subject": { |
|
"url": "https://www.google.com" |
|
}, |
|
"last_submission_timestamp": "2019-11-18 16:11:04" |
|
} |
|
|
|
OR the following if the analysis is still pending: |
|
|
|
{ |
|
"submission_timestamp": "2019-11-18 13:59:25", |
|
"task_uuid": "f3c0ae115d51001017ff8da768fa6049", |
|
} |
|
""" |
|
api_url = self._build_url("analysis", ["submit", "url"]) |
|
params = purge_none({ |
|
"url": url, |
|
"referer": referer, |
|
"bypass_cache": bypass_cache and 1 or None, |
|
"user_agent": user_agent or None, |
|
}) |
|
return self.do_request("POST", api_url, params=params) |
|
|
|
|
|
class PortalClient(LastlineAbstractClient): |
|
|
|
def _login(self): |
|
""" |
|
Login using account-based or key-based methods. |
|
|
|
Credentials are 'username' and 'password' |
|
""" |
|
if self._session is None: |
|
self._session = requests.session() |
|
self.post("login", function=None, data=self._login_params) |
|
|
|
def get_progress(self, uuid, analysis_instance=None): |
|
""" |
|
Get the completion progress of a given task. |
|
|
|
:param str uuid: the unique identifier of the submitted task |
|
:param str analysis_instance: if set, defines the analysis instance to query |
|
:rtype: dict[str, int] |
|
:return: a dictionary like the the following: |
|
{ |
|
"completed": 1, |
|
"progress": 100 |
|
} |
|
""" |
|
params = purge_none({"uuid": uuid, "analysis_instance": analysis_instance}) |
|
return self.get("analysis", "get_progress", params=params) |
|
|
|
def get_result(self, uuid, analysis_instance=None): |
|
""" |
|
Get report results for a given task. |
|
|
|
:param str uuid: the unique identifier of the submitted task |
|
:param str analysis_instance: if set, defines the analysis instance to query |
|
:rtype: dict[str, any] |
|
:return: a dictionary like the the following: |
|
{ |
|
"completed": 1, |
|
"progress": 100 |
|
} |
|
""" |
|
params = purge_none( |
|
{ |
|
"uuid": uuid, |
|
"analysis_instance": analysis_instance, |
|
"report_format": "json", |
|
} |
|
) |
|
return self.get("analysis", "get_result", params=params) |
|
|
|
def submit_url( |
|
self, |
|
url, |
|
referer=None, |
|
user_agent=None, |
|
bypass_cache=False, |
|
): |
|
""" |
|
Upload an URL to be analyzed. |
|
|
|
:param str url: the url to analyze |
|
:param str|None referer: the referer |
|
:param str|None user_agent: the user agent |
|
:param boolean bypass_cache: bypass_cache |
|
:rtype: dict[str, any] |
|
:return: a dictionary like the following if the analysis is already available: |
|
{ |
|
"submission": "2019-11-17 09:33:23", |
|
"child_tasks": [...], |
|
"reports": [...], |
|
"submission_timestamp": "2019-11-18 16:11:04", |
|
"task_uuid": "86097fb8e4cd00100464cb001b97ecbe", |
|
"score": 0, |
|
"analysis_subject": { |
|
"url": "https://www.google.com" |
|
}, |
|
"last_submission_timestamp": "2019-11-18 16:11:04" |
|
} |
|
|
|
OR the following if the analysis is still pending: |
|
|
|
{ |
|
"submission_timestamp": "2019-11-18 13:59:25", |
|
"task_uuid": "f3c0ae115d51001017ff8da768fa6049", |
|
} |
|
""" |
|
params = purge_none( |
|
{ |
|
"url": url, |
|
"bypass_cache": bypass_cache, |
|
"referer": referer, |
|
"user_agent": user_agent |
|
} |
|
) |
|
return self.post("analysis", "submit_url", params=params) |
|
|
|
def submit_file( |
|
self, |
|
file_data, |
|
file_name=None, |
|
password=None, |
|
analysis_env=None, |
|
allow_network_traffic=True, |
|
analysis_timeout=None, |
|
bypass_cache=False, |
|
): |
|
""" |
|
Upload a file to be analyzed. |
|
|
|
:param bytes file_data: the data as a byte sequence |
|
:param str|None file_name: if set, represents the name of the file to submit |
|
:param str|None password: if set, use it to extract the sample |
|
:param str|None analysis_env: if set, e.g windowsxp |
|
:param boolean allow_network_traffic: if set to False, deny network connections |
|
:param int|None analysis_timeout: if set limit the duration of the analysis |
|
:param boolean bypass_cache: whether to re-process a file (requires special permissions) |
|
:rtype: dict[str, any] |
|
:return: a dictionary in the following form if the analysis is already available: |
|
{ |
|
"submission": "2019-11-17 09:33:23", |
|
"child_tasks": [...], |
|
"reports": [...], |
|
"submission_timestamp": "2019-11-18 16:11:04", |
|
"task_uuid": "86097fb8e4cd00100464cb001b97ecbe", |
|
"score": 0, |
|
"analysis_subject": { |
|
"url": "https://www.google.com" |
|
}, |
|
"last_submission_timestamp": "2019-11-18 16:11:04" |
|
} |
|
|
|
OR the following if the analysis is still pending: |
|
|
|
{ |
|
"submission_timestamp": "2019-11-18 13:59:25", |
|
"task_uuid": "f3c0ae115d51001017ff8da768fa6049", |
|
} |
|
""" |
|
params = purge_none( |
|
{ |
|
"filename": file_name, |
|
"password": password, |
|
"analysis_env": analysis_env, |
|
"allow_network_traffic": allow_network_traffic, |
|
"analysis_timeout": analysis_timeout, |
|
"bypass_cache": bypass_cache, |
|
} |
|
) |
|
files = {"file": (file_name, file_data, "application/octet-stream")} |
|
return self.post("analysis", "submit_file", params=params, files=files) |
|
|
|
|
|
class LastlineResultBaseParser(object): |
|
""" |
|
This is a parser to extract *basic* information from a Lastline result dictionary. |
|
|
|
Note: this is a *version 0*: the information we extract is merely related to the behaviors and |
|
the HTTP connections. Further iterations will include host activity such as files, mutexes, |
|
registry keys, strings, etc. |
|
""" |
|
|
|
def __init__(self): |
|
"""Constructor.""" |
|
self.misp_event = None |
|
|
|
@staticmethod |
|
def _get_mitre_techniques(result): |
|
return [ |
|
"misp-galaxy:mitre-attack-pattern=\"{} - {}\"".format(w[0], w[1]) |
|
for w in sorted(set([ |
|
(y["id"], y["name"]) |
|
for x in result.get("malicious_activity", []) |
|
for y in result.get("activity_to_mitre_techniques", {}).get(x, []) |
|
])) |
|
] |
|
|
|
def parse(self, analysis_link, result): |
|
""" |
|
Parse the analysis result into a MISP event. |
|
|
|
:param str analysis_link: the analysis link |
|
:param dict[str, any] result: the JSON returned by the analysis client. |
|
:rtype: MISPEvent |
|
:return: some results that can be consumed by MIPS. |
|
""" |
|
self.misp_event = pymisp.MISPEvent() |
|
|
|
# Add analysis subject info |
|
if "url" in result["analysis_subject"]: |
|
o = pymisp.MISPObject("url") |
|
o.add_attribute("url", result["analysis_subject"]["url"]) |
|
else: |
|
o = pymisp.MISPObject("file") |
|
o.add_attribute("md5", type="md5", value=result["analysis_subject"]["md5"]) |
|
o.add_attribute("sha1", type="sha1", value=result["analysis_subject"]["sha1"]) |
|
o.add_attribute("sha256", type="sha256", value=result["analysis_subject"]["sha256"]) |
|
o.add_attribute( |
|
"mimetype", |
|
type="mime-type", |
|
value=result["analysis_subject"]["mime_type"] |
|
) |
|
self.misp_event.add_object(o) |
|
|
|
# Add HTTP requests from url analyses |
|
network_dict = result.get("report", {}).get("analysis", {}).get("network", {}) |
|
for request in network_dict.get("requests", []): |
|
parsed_uri = parse.urlparse(request["url"]) |
|
o = pymisp.MISPObject(name='http-request') |
|
o.add_attribute('host', parsed_uri.netloc) |
|
o.add_attribute('method', "GET") |
|
o.add_attribute('uri', request["url"]) |
|
o.add_attribute("ip", request["ip"]) |
|
self.misp_event.add_object(o) |
|
|
|
# Add network behaviors from files |
|
for subject in result.get("report", {}).get("analysis_subjects", []): |
|
|
|
# Add DNS requests |
|
for dns_query in subject.get("dns_queries", []): |
|
hostname = dns_query.get("hostname") |
|
# Skip if it is an IP address |
|
try: |
|
if hostname == "wpad": |
|
continue |
|
_ = ipaddress.ip_address(hostname) |
|
continue |
|
except ValueError: |
|
pass |
|
|
|
o = pymisp.MISPObject(name='dns-record') |
|
o.add_attribute('queried-domain', hostname) |
|
self.misp_event.add_object(o) |
|
|
|
# Add HTTP conversations (as network connection and as http request) |
|
for http_conversation in subject.get("http_conversations", []): |
|
o = pymisp.MISPObject(name="network-connection") |
|
o.add_attribute("ip-src", http_conversation["src_ip"]) |
|
o.add_attribute("ip-dst", http_conversation["dst_ip"]) |
|
o.add_attribute("src-port", http_conversation["src_port"]) |
|
o.add_attribute("dst-port", http_conversation["dst_port"]) |
|
o.add_attribute("hostname-dst", http_conversation["dst_host"]) |
|
o.add_attribute("layer3-protocol", "IP") |
|
o.add_attribute("layer4-protocol", "TCP") |
|
o.add_attribute("layer7-protocol", "HTTP") |
|
self.misp_event.add_object(o) |
|
|
|
method, path, http_version = http_conversation["url"].split(" ") |
|
if http_conversation["dst_port"] == 80: |
|
uri = "http://{}{}".format(http_conversation["dst_host"], path) |
|
else: |
|
uri = "http://{}:{}{}".format( |
|
http_conversation["dst_host"], |
|
http_conversation["dst_port"], |
|
path |
|
) |
|
o = pymisp.MISPObject(name='http-request') |
|
o.add_attribute('host', http_conversation["dst_host"]) |
|
o.add_attribute('method', method) |
|
o.add_attribute('uri', uri) |
|
o.add_attribute('ip', http_conversation["dst_ip"]) |
|
self.misp_event.add_object(o) |
|
|
|
# Add sandbox info like score and sandbox type |
|
o = pymisp.MISPObject(name="sandbox-report") |
|
sandbox_type = "saas" if is_task_hosted(analysis_link) else "on-premise" |
|
o.add_attribute("score", result["score"]) |
|
o.add_attribute("sandbox-type", sandbox_type) |
|
o.add_attribute("{}-sandbox".format(sandbox_type), "lastline") |
|
o.add_attribute("permalink", analysis_link) |
|
self.misp_event.add_object(o) |
|
|
|
# Add behaviors |
|
o = pymisp.MISPObject(name="sb-signature") |
|
o.add_attribute("software", "Lastline") |
|
for activity in result.get("malicious_activity", []): |
|
a = pymisp.MISPAttribute() |
|
a.from_dict(type="text", value=activity) |
|
o.add_attribute("signature", **a) |
|
self.misp_event.add_object(o) |
|
|
|
# Add mitre techniques |
|
for technique in self._get_mitre_techniques(result): |
|
self.misp_event.add_tag(technique)
|
|
|