""" 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)