import demistomock as demisto from CommonServerPython import * from CommonServerUserPython import * """IMPORTS""" import ast from _collections import defaultdict from operator import itemgetter from typing import Any import urllib3 # Disable insecure warnings urllib3.disable_warnings() """ CONSTANTS """ API_VERSION = "/api/now/cmdb/instance/" CREAT_RECORD_DATA_FIELDS = ["attributes", "inbound_relations", "outbound_relations", "source"] UPDATE_RECORD_DATA_FIELDS = ["attributes", "source"] ADD_RELATION_DATA_FIELDS = ["inbound_relations", "outbound_relations", "source"] FIELD_TO_OUTPUT = {"inbound_relations": "Inbound Relations", "outbound_relations": "Outbound Relations"} class Client: """Client class to interact with the service API This Client implements API calls, and does not contain any Demisto logic. Should only do requests and return data. It inherits from BaseClient defined in CommonServer Python. Most calls use _http_request() that handles proxy, SSL verification, etc. """ def __init__( self, username: str = "", password: str = "", use_oauth: bool = False, client_id: str = "", client_secret: str = "", url: str = "", verify: bool = False, proxy: bool = False, jwt_params: dict = None, ): """ Args: - username: the username for authentication. - password: the password for authentication. - client_id: the client id of the application of the user. - client_secret - the client secret of the application of the user. - url: the instance url of the user, i.e: https://.service-now.com. NOTE - url should be given without an API specific suffix as it is also used for the OAuth process. - insecure: Whether the request should verify the SSL certificate. - proxy: Whether to run the integration using the system proxy. - headers: The request headers, for example: {'Accept`: `application/json`}. Can be None. - use_oauth: a flag indicating whether the user wants to use OAuth 2.0 or basic authorization. """ headers = {"Content-Type": "application/json", "Accept": "application/json"} self.use_oauth = use_oauth self.use_jwt = bool(jwt_params) self.snow_client: ServiceNowClient = ServiceNowClient( username=username, password=password, use_oauth=use_oauth, client_id=client_id, client_secret=client_secret, url=url, verify=verify, proxy=proxy, headers=headers, jwt_params=jwt_params, ) def records_list(self, class_name, params=None): return self.snow_client.http_request(method="GET", url_suffix=f"{API_VERSION}{class_name}", params=params) def get_record(self, class_name, sys_id, params=None): url_suffix = f"{API_VERSION}{class_name}/{sys_id}" return self.snow_client.http_request(method="GET", url_suffix=url_suffix, params=params) def create_record(self, class_name, data, params=None): return self.snow_client.http_request(method="POST", url_suffix=f"{API_VERSION}{class_name}", params=params, data=data) def update_record(self, class_name, sys_id, data, params=None): url_suffix = f"{API_VERSION}{class_name}/{sys_id}" return self.snow_client.http_request(method="PATCH", url_suffix=url_suffix, params=params, data=data) def add_relation(self, class_name, sys_id, data, params=None): url_suffix = f"{API_VERSION}{class_name}/{sys_id}/relation" return self.snow_client.http_request(method="POST", url_suffix=url_suffix, params=params, data=data) def delete_relation(self, class_name, sys_id, rel_sys_id, params=None): url_suffix = f"{API_VERSION}{class_name}/{sys_id}/relation/{rel_sys_id}" return self.snow_client.http_request(method="DELETE", url_suffix=url_suffix, params=params) """ HELPER FUNCTIONS """ def create_request_data(data_fields: list, args: dict) -> dict: """ This function converts the input given by the user when creating a new record to a data dict that should be passed in the http request. Args: data_fields: A list with the fields that should be added to the data. args: The arguments that were filled by the user. Returns: A dictionary representing the data parameter that should be sent in the http request. """ data = {} for field in data_fields: if field == "source": data[field] = args.get(field) elif field == "attributes": # 'attributes' input should be of the form key1=value1,key2=value2... val = args.get(field) if val: try: attributes_dict = {} attributes_input = val.split(",") for attribute in attributes_input: pair = attribute.split("=") attributes_dict[pair[0]] = pair[1] data[field] = attributes_dict except Exception: raise Exception( 'Illegal input. Input format should be "key=value". Multiple values can be filled, ' "separated by a comma." ) else: # other fields should be converted to dict/list val = args.get(field) if val: try: data[field] = ast.literal_eval(val) except Exception: raise Exception("Illegal input. Please see the argument description for the correct input format.") return data def create_record_context(class_name: str, sys_id: str, result: dict) -> dict: """ Create the context output for commands that operate on a single record. Args: class_name: The class name of the record used. sys_id: The id of the record. result: The raw response from the http request. Return: A dictionary representing the context output for the record. """ context = { "ServiceNowCMDB.Record(val.ID===obj.ID)": { "Class": class_name, "SysID": sys_id, "Attributes": result.get("attributes", {}), "InboundRelations": result.get("inbound_relations", []), "OutboundRelations": result.get("outbound_relations", []), } } return context def create_human_readable(title: str, result: dict, fields: str) -> str: """ Create the human readable output for commands. Args: title: The title of the human readable output. result: The raw response from the http request consisting of the attributes, inbound_relations and outbound_relations fields. fields: A string representing all the fields of the record the client specified that should be returned. If no fields were specified, only the record name and sys_id will be displayed in the war room. Return: A string representing the markdown output that should be displayed in the war room. """ md = f"{title}\n" attributes_outputs = {} if fields: for field in fields.split(","): if result.get("attributes", {}).get(field): attributes_outputs[string_to_context_key(field)] = result.get("attributes", {}).get(field) else: attributes_outputs = { "SysID": result.get("attributes", {}).get("sys_id"), "Name": result.get("attributes", {}).get("name"), } md += tableToMarkdown("Attributes", t=attributes_outputs, removeNull=True) for relation_type in ["inbound_relations", "outbound_relations"]: relations = result.get(relation_type) if relations: relation_output = { "SysID": list(map(itemgetter("sys_id"), relations)), "Target Display Value": list( map( itemgetter("display_value"), list(map(itemgetter("target"), result.get(relation_type))), # type: ignore[arg-type] ) ), # type: ignore "Type Display Value": list( map( itemgetter("display_value"), list(map(itemgetter("type"), result.get(relation_type))), # type: ignore[arg-type] ) ), # type: ignore } md += f" {tableToMarkdown(FIELD_TO_OUTPUT.get(relation_type), t=relation_output)}" return md """ COMMAND FUNCTIONS """ def records_list_command(client: Client, args: dict) -> tuple[str, dict, Any]: """ Query a CMDB table using the class name to receive all records in the class. Args: client: Client object with request. args: Usually demisto.args() Returns: Demisto Outputs. """ context: dict = defaultdict(list) class_name = args.get("class") params = {} if args.get("query"): params["sysparm_query"] = args.get("query") if args.get("limit"): params["sysparm_limit"] = args.get("limit") if args.get("offset"): params["sysparm_offset"] = args.get("offset") outputs = {"Class": class_name} response = client.records_list(class_name=class_name, params=params) result = response.get("result", {}) if result: outputs["Records"] = result human_readable = tableToMarkdown(f"Found {len(result)} records for class {class_name}:", t=result) else: human_readable = f"Found no records for class {class_name}." context["ServiceNowCMDB(val.ID===obj.ID)"] = outputs return human_readable, context, response def get_record_command(client: Client, args: dict) -> tuple[str, dict, Any]: """ Query attributes and relationship information for a specific record. Args: client: Client object with request. args: Usually demisto.args() Returns: Demisto Outputs. """ context: dict = defaultdict(list) class_name = args.get("class") sys_id = args.get("sys_id") params: dict = {} if args.get("fields"): params["sysparm_fields"] = args.get("fields") # Verify that sys_id and name were added so they can be used in the output of the command: if "sys_id" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",sys_id" if "name" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",name" if args.get("relation_limit"): params["sysparm_relation_limit"] = args.get("relation_limit") if args.get("relation_offset"): params["sysparm_relation_offset"] = args.get("relation_offset") response = client.get_record(class_name=class_name, sys_id=sys_id, params=params) result = response.get("result") if result: context["ServiceNowCMDB.Record(val.ID===obj.ID)"] = { "Class": class_name, "SysID": sys_id, "Attributes": result.get("attributes", {}), "InboundRelations": result.get("inbound_relations", []), "OutboundRelations": result.get("outbound_relations", []), } hr_title = f"### Found the following attributes and relations for record {sys_id}:" human_readable = create_human_readable(hr_title, result, params.get("sysparm_fields", "")) else: context["ServiceNowCMDB.Record(val.ID===obj.ID)"] = {"Class": class_name, "SysID": sys_id} human_readable = f"Found no attributes and relations for record {sys_id}." return human_readable, context, response def create_record_command(client: Client, args: dict) -> tuple[str, dict, Any]: """ Create a record with associated relations. Args: client: Client object with request. args: Usually demisto.args() Returns: Demisto Outputs. """ context: dict = defaultdict(list) class_name = args.get("class", "") params: dict = {} if args.get("fields"): params["sysparm_fields"] = args.get("fields") # Verify that sys_id and name were added so they can be used in the output of the command: if "sys_id" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",sys_id" if "name" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",name" if args.get("relation_limit"): params["sysparm_relation_limit"] = args.get("relation_limit") if args.get("relation_offset"): params["sysparm_relation_offset"] = args.get("relation_offset") data = create_request_data(CREAT_RECORD_DATA_FIELDS, args) response = client.create_record(class_name=class_name, params=params, data=str(data)) result = response.get("result") if result: sys_id = result.get("attributes", {}).get("sys_id") context = create_record_context(class_name, sys_id, result) hr_title = f"### Record {sys_id} was created successfully." human_readable = create_human_readable(hr_title, result, params.get("sysparm_fields", "")) else: human_readable = "Failed to create a new record." return human_readable, context, response def update_record_command(client: Client, args: dict) -> tuple[str, dict, Any]: """ Update a record with attributes given by the user. Args: client: Client object with request. args: Usually demisto.args() Returns: Demisto Outputs. """ context: dict = defaultdict(list) class_name = args.get("class", "") sys_id = args.get("sys_id", "") params: dict = {} if args.get("fields"): params["sysparm_fields"] = args.get("fields") # Verify that sys_id and name were added so they can be used in the output of the command: if "sys_id" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",sys_id" if "name" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",name" if args.get("relation_limit"): params["sysparm_relation_limit"] = args.get("relation_limit") if args.get("relation_offset"): params["sysparm_relation_offset"] = args.get("relation_offset") data = create_request_data(UPDATE_RECORD_DATA_FIELDS, args) response = client.update_record(class_name=class_name, sys_id=sys_id, data=str(data), params=params) result = response.get("result") if result: context = create_record_context(class_name, sys_id, result) hr_title = f"### Updated record {sys_id} successfully." human_readable = create_human_readable(hr_title, result, params.get("sysparm_fields", "")) else: human_readable = f"Failed to update record {sys_id}." return human_readable, context, response def add_relation_command(client: Client, args: dict) -> tuple[str, dict, Any]: """ Add new relations to an existing record. Args: client: Client object with request. args: Usually demisto.args() Returns: Demisto Outputs. """ context: dict = defaultdict(list) class_name = args.get("class", "") sys_id = args.get("sys_id", "") params: dict = {} if args.get("fields"): params["sysparm_fields"] = args.get("fields") # Verify that sys_id and name were added so they can be used in the output of the command: if "sys_id" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",sys_id" if "name" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",name" if args.get("relation_limit"): params["sysparm_relation_limit"] = args.get("relation_limit") if args.get("relation_offset"): params["sysparm_relation_offset"] = args.get("relation_offset") data = create_request_data(ADD_RELATION_DATA_FIELDS, args) response = client.add_relation(class_name=class_name, sys_id=sys_id, data=str(data), params=params) result = response.get("result") if result: context = create_record_context(class_name, sys_id, result) hr_title = f"### New relations were added to {sys_id} record successfully." human_readable = create_human_readable(hr_title, result, params.get("sysparm_fields", "")) else: human_readable = f"Failed to add new relations to record {sys_id}." return human_readable, context, response def delete_relation_command(client: Client, args: dict) -> tuple[str, dict, Any]: """ Delete relations for an existing record. Args: client: Client object with request. args: Usually demisto.args() Returns: Demisto Outputs. """ context: dict = defaultdict(list) class_name = args.get("class", "") sys_id = args.get("sys_id", "") rel_sys_id = args.get("relation_sys_id", "") params: dict = {} if args.get("fields"): params["sysparm_fields"] = args.get("fields") # Verify that sys_id and name were added so they can be used in the output of the command: if "sys_id" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",sys_id" if "name" not in params.get("sysparm_fields", ""): params["sysparm_fields"] += ",name" if args.get("relation_limit"): params["sysparm_relation_limit"] = args.get("relation_limit") if args.get("relation_offset"): params["sysparm_relation_offset"] = args.get("relation_offset") response = client.delete_relation(class_name=class_name, sys_id=sys_id, rel_sys_id=rel_sys_id, params=params) result = response.get("result") if result: context = create_record_context(class_name, sys_id, result) hr_title = f"### Deleted relation {rel_sys_id} successfully from {sys_id} record." human_readable = create_human_readable(hr_title, result, params.get("sysparm_fields", "")) else: human_readable = f"Failed to delete relation {rel_sys_id} from record {sys_id}." return human_readable, context, response def test_module(client: Client) -> str: """Tests API connectivity and authentication' Returning 'ok' indicates that the integration works like it is supposed to. Connection to the service is successful. Raises exceptions if something goes wrong. :type client: ``Client`` :param Client: ServiceNow CMDB client to use :return: 'ok' if test passed, anything else will fail the test. :rtype: ``str`` """ try: client.records_list(class_name="cmdb_ci_linux_server") except Exception as e: raise e return "ok" def oauth_test_module(client: Client, *_) -> tuple[str, dict[Any, Any], dict[Any, Any]]: """ Test the instance configurations when using OAuth authorization. """ if not client.use_oauth: return_error( "!servicenow-cmdb-oauth-test command should be used only when using OAuth 2.0 authorization.\n " "Please select the `Use OAuth Login` checkbox in the instance configuration before running this " "command." ) try: client.records_list(class_name="cmdb_ci_linux_server") except Exception as e: raise e hr = "### Instance Configured Successfully.\n" return hr, {}, {} def login_command(client: Client, args: dict[str, Any]) -> tuple[str, dict[Any, Any], dict[Any, Any]]: """ Login the user using OAuth authorization Args: client: Client object with request. args: Usually demisto.args() Returns: Demisto Outputs. """ # Verify that the user selected the `Use OAuth Login` checkbox: if not client.use_oauth: return_error( "!servicenow-cmdb-oauth-login command can be used only when using OAuth 2.0 authorization.\n " "Please select the `Use OAuth Login` checkbox in the instance configuration before running this " "command." ) username = args.get("username", "") password = args.get("password", "") try: client.snow_client.login(username, password) hr = ( "### Logged in successfully.\n A refresh token was saved to the integration context and will be " "used to generate a new access token once the current one expires." ) except Exception as e: return_error( f"Failed to login. Please verify that the provided username and password are correct, and that you" f" entered the correct client id and client secret in the instance configuration (see ? for" f"correct usage when using OAuth).\n\n{e}" ) return hr, {}, {} """ MAIN FUNCTION """ def main() -> None: """main function, parses params and runs command functions""" params = demisto.params() url = params.get("url", "") verify = not params.get("insecure", False) proxy = params.get("proxy", False) client_id = client_secret = "" use_oauth = params.get("use_oauth", False) use_jwt = params.get("use_jwt", False) jwt_params = {} basic_auth_creds = params.get("basic_credentials", {}) username = basic_auth_creds.get("identifier", "") password = basic_auth_creds.get("password", "") oauth_creds = params.get("credentials", {}) try: # use jwt only with OAuth if use_jwt and use_oauth: raise ValueError("Please choose only one authentication method (OAuth or JWT).") elif use_jwt: use_oauth = True if use_oauth: client_id = oauth_creds.get("identifier", "") client_secret = oauth_creds.get("password", "") else: # if username/password are empty - fallback to legacy which populates the oath credentials if not username or not password: demisto.debug("Using legacy parameters for username and password") username = oauth_creds.get("identifier", "") password = oauth_creds.get("password", "") if use_jwt: if not params.get("private_key") or not params.get("kid") or not params.get("sub"): raise Exception("When using JWT, fill private key, kid and sub fields") jwt_params = { "private_key": params.get("private_key", {}).get("password"), "kid": params.get("kid"), "sub": params.get("sub"), "iss": params.get("iss", client_id), "aud": client_id, } client = Client( username=username, password=password, use_oauth=use_oauth, client_id=client_id, client_secret=client_secret, url=url, verify=verify, proxy=proxy, jwt_params=jwt_params, ) commands = { "servicenow-cmdb-oauth-login": login_command, "servicenow-cmdb-oauth-test": oauth_test_module, "servicenow-cmdb-records-list": records_list_command, "servicenow-cmdb-record-get-by-id": get_record_command, "servicenow-cmdb-record-create": create_record_command, "servicenow-cmdb-record-update": update_record_command, "servicenow-cmdb-record-add-relations": add_relation_command, "servicenow-cmdb-record-delete-relations": delete_relation_command, } except Exception as e: return_error(f"Error:\n{e!s}") try: command = demisto.command() demisto.debug(f"Command being called is {command}") if demisto.command() == "test-module": # This is the call made when pressing the integration Test button. result = test_module(client) return_results(result) elif command in commands: return_outputs(*commands[command](client, demisto.args())) # type: ignore else: return_error("Command not found.") # Log exceptions and return errors except Exception as e: return_error(f"Failed to execute {command} command.\nError:\n{e!s}") from ServiceNowApiModule import * # noqa: E402 """ ENTRY POINT """ if __name__ in ("__main__", "__builtin__", "builtins"): main()