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.

1542 lines
59 KiB

# File: msadgraph_connector.py
#
# Copyright (c) 2022-2023 Splunk Inc.
#
# 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.
#
#
# Phantom App imports
import grp
import json
import os
import pathlib
import pwd
import sys
import time
import urllib.parse as urlparse
import encryption_helper
import phantom.app as phantom
import requests
from bs4 import BeautifulSoup
from django.http import HttpResponse
from phantom.action_result import ActionResult
from phantom.base_connector import BaseConnector
from msadgraph_consts import *
MAX_END_OFFSET_VAL = 2147483646
def _handle_login_redirect(request, key):
""" This function is used to redirect login request to microsoft login page.
:param request: Data given to REST endpoint
:param key: Key to search in state file
:return: response authorization_url/admin_consent_url
"""
asset_id = request.GET.get('asset_id')
if not asset_id:
return HttpResponse('ERROR: Asset ID not found in URL', content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
state = _load_app_state(asset_id)
if not state:
return HttpResponse('ERROR: Invalid asset_id', content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
url = state.get(key)
if not url:
return HttpResponse(f'App state is invalid, {key} not found.', content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
response = HttpResponse(status=302)
response['Location'] = url
return response
def _is_valid_asset_id(asset_id):
""" This function validates an asset id.
Must be an alphanumeric string of less than 128 characters.
:param asset_id: asset_id
:return: is_valid: Boolean True if valid, False if not.
"""
if not isinstance(asset_id, str):
return False
if not asset_id.isalnum():
return False
if len(asset_id) > 128:
return False
return True
def _get_file_path(asset_id, is_state_file=True):
""" This function gets the path of the auth status file of an asset id.
:param asset_id: asset_id
:param app_connector: Object of app_connector class
:param is_state_file: boolean parameter for state file
:return: file_path: Path object of the file
"""
current_file_path = pathlib.Path(__file__).resolve()
if is_state_file:
input_file = f'{asset_id}_state.json'
else:
input_file = f'{asset_id}_oauth_task.out'
output_file_path = current_file_path.with_name(input_file)
return output_file_path
def _decrypt_state(state, salt):
"""
Decrypts the state.
:param state: state dictionary
:param salt: salt used for decryption
:return: decrypted state
"""
if not state.get("is_encrypted"):
return state
access_token = state.get("token", {}).get("access_token")
if access_token:
state["token"]["access_token"] = encryption_helper.decrypt(access_token, salt)
refresh_token = state.get("token", {}).get("refresh_token")
if refresh_token:
state["token"]["refresh_token"] = encryption_helper.decrypt(refresh_token, salt)
code = state.get("code")
if code:
state["code"] = encryption_helper.decrypt(code, salt)
return state
def _encrypt_state(state, salt):
"""
Encrypts the state.
:param state: state dictionary
:param salt: salt used for encryption
:return: encrypted state
"""
access_token = state.get("token", {}).get("access_token")
if access_token:
state["token"]["access_token"] = encryption_helper.encrypt(access_token, salt)
refresh_token = state.get("token", {}).get("refresh_token")
if refresh_token:
state["token"]["refresh_token"] = encryption_helper.encrypt(refresh_token, salt)
code = state.get("code")
if code:
state["code"] = encryption_helper.encrypt(code, salt)
state["is_encrypted"] = True
return state
def _load_app_state(asset_id, app_connector=None):
""" This function is used to load the current state file.
:param asset_id: asset_id
:param app_connector: Object of app_connector class
:return: state: Current state file as a dictionary
"""
asset_id = str(asset_id)
if not _is_valid_asset_id(asset_id):
if app_connector:
app_connector.debug_print('In _load_app_state: Invalid asset_id')
return {}
state_file_path = _get_file_path(asset_id)
state = {}
try:
with open(state_file_path, 'r') as state_file:
state = json.load(state_file)
except Exception as e:
if app_connector:
app_connector.error_print(f'In _load_app_state: Exception: {str(e)}')
if app_connector:
app_connector.debug_print('Loaded state: ', state)
try:
state = _decrypt_state(state, asset_id)
except Exception as e:
if app_connector:
app_connector.error_print("{}: {}".format(MS_AZURE_DECRYPTION_ERROR, str(e)))
state = {}
return state
def _save_app_state(state, asset_id, app_connector):
""" This function is used to save current state in file.
:param state: Dictionary which contains data to write in state file
:param asset_id: asset_id
:param app_connector: Object of app_connector class
:return: status: phantom.APP_SUCCESS
"""
asset_id = str(asset_id)
if not _is_valid_asset_id(asset_id):
if app_connector:
app_connector.debug_print('In _save_app_state: Invalid asset_id')
return {}
state_file_path = _get_file_path(asset_id)
try:
state = _encrypt_state(state, asset_id)
except Exception as e:
if app_connector:
app_connector.error_print("{}: {}".format(MS_AZURE_ENCRYPTION_ERROR, str(e)))
return phantom.APP_ERROR
if app_connector:
app_connector.debug_print('Saving state: ', state)
try:
with open(state_file_path, 'w+') as state_file:
json.dump(state, state_file)
except Exception as e:
if app_connector:
app_connector.error_print(f'Unable to save state file: {str(e)}')
return phantom.APP_SUCCESS
def _handle_login_response(request):
""" This function is used to get the login response of authorization request from microsoft login page.
:param request: Data given to REST endpoint
:return: HttpResponse. The response displayed on authorization URL page
"""
asset_id = request.GET.get('state')
if not asset_id:
return HttpResponse(f'ERROR: Asset ID not found in URL\n{json.dumps(request.GET)}',
content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
# Check for error in URL
error = request.GET.get('error')
error_description = request.GET.get('error_description')
# If there is an error in response
if error:
message = f'Error: {error}'
if error_description:
message = f'{message} Details: {error_description}'
return HttpResponse(f'Server returned {message}', content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
code = request.GET.get('code')
admin_consent = request.GET.get('admin_consent')
# If none of the code or admin_consent is available
if not (code or admin_consent):
return HttpResponse(f'Error while authenticating\n{json.dumps(request.GET)}',
content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
state = _load_app_state(asset_id)
# If value of admin_consent is available
if admin_consent:
if admin_consent == 'True':
admin_consent = True
else:
admin_consent = False
state['admin_consent'] = admin_consent
_save_app_state(state, asset_id, None)
# If admin_consent is True
if admin_consent:
return HttpResponse('Admin Consent received. Please close this window.', content_type="text/plain")
return HttpResponse('Admin Consent declined. Please close this window and try again later.',
content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
# If value of admin_consent is not available, value of code is available
state['code'] = code
_save_app_state(state, asset_id, None)
return HttpResponse('Code received. Please close this window, the action will continue to get new token.', content_type="text/plain")
def _handle_rest_request(request, path_parts):
""" Handle requests for authorization.
:param request: Data given to REST endpoint
:param path_parts: parts of the URL passed
:return: dictionary containing response parameters
"""
if len(path_parts) < 2:
return HttpResponse('error: True, message: Invalid REST endpoint request', content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
call_type = path_parts[1]
# To handle authorize request in test connectivity action
if call_type == 'start_oauth':
return _handle_login_redirect(request, 'admin_consent_url')
# To handle response from microsoft login page
if call_type == 'result':
return_val = _handle_login_response(request)
asset_id = request.GET.get('state')
if asset_id:
if not _is_valid_asset_id(asset_id):
return HttpResponse("Error: Invalid asset_id", content_type="text/plain", status=MS_AZURE_BAD_REQUEST_CODE)
auth_status_file_path = _get_file_path(asset_id, is_state_file=False)
auth_status_file_path.touch(mode=664, exist_ok=True)
try:
uid = pwd.getpwnam('apache').pw_uid
gid = grp.getgrnam('phantom').gr_gid
os.chown(auth_status_file_path, uid, gid) # nosemgrep file traversal risk is handled by blocking non-alphanum strings
except Exception:
pass
return return_val
return HttpResponse('error: Invalid endpoint', content_type="text/plain", status=MS_AZURE_NOT_FOUND_CODE)
def _get_dir_name_from_app_name(app_name):
""" Get name of the directory for the app.
:param app_name: Name of the application for which directory name is required
:return: app_name: Name of the directory for the application
"""
app_name = ''.join([x for x in app_name if x.isalnum()])
app_name = app_name.lower()
if not app_name:
app_name = 'app_for_phantom'
return app_name
class RetVal(tuple):
def __new__(cls, val1, val2):
return tuple.__new__(RetVal, (val1, val2))
class MSADGraphConnector(BaseConnector):
def __init__(self):
# Call the BaseConnectors init first
super(MSADGraphConnector, self).__init__()
self._state = None
self._tenant = None
self._client_id = None
self._client_secret = None
self._access_token = None
self._refresh_token = None
self._base_url = None
self._admin_access_required = None
self._admin_access_granted = None
def load_state(self):
"""
Load the contents of the state file to the state dictionary and decrypt it.
:return: loaded state
"""
state = super().load_state()
if not isinstance(state, dict):
self.debug_print("Reseting the state file with the default format")
state = {
"app_version": self.get_app_json().get('app_version')
}
return state
try:
state = _decrypt_state(state, self.get_asset_id())
except Exception as e:
error_message = self._get_error_message_from_exception(e)
self.error_print("{}: {}".format(MS_AZURE_DECRYPTION_ERROR, error_message))
self.debug_print("Reseting the state file with the default format")
state = {
"app_version": self.get_app_json().get('app_version')
}
return state
def save_state(self, state):
"""
Encrypt and save the current state dictionary to the the state file.
:param state: state dictionary
:return: status
"""
try:
state = _encrypt_state(state, self.get_asset_id())
except Exception as e:
error_message = self._get_error_message_from_exception(e)
self.error_print("{}: {}".format(MS_AZURE_ENCRYPTION_ERROR, error_message))
return super().save_state(state)
def _dump_error_log(self, error, message="Exception occurred."):
self.error_print(message, dump_object=error)
def _get_error_message_from_exception(self, e):
"""
Get appropriate error message from the exception.
:param e: Exception object
:return: error message
"""
error_code = None
error_message = MS_AZURE_ERROR_MESSAGE_UNKNOWN
self._dump_error_log(e)
try:
if hasattr(e, "args"):
if len(e.args) > 1:
error_code = e.args[0]
error_message = e.args[1]
elif len(e.args) == 1:
error_message = e.args[0]
except Exception:
self.error_print("Exception occurred while getting error code and message")
if not error_code:
error_text = "Error Message: {}".format(error_message)
else:
error_text = "Error Code: {}. Error Message: {}".format(error_code, error_message)
return error_text
def _process_empty_response(self, response, action_result):
""" This function is used to process empty response.
:param response: response data
:param action_result: object of Action Result
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message)
"""
if response.status_code == 200 or response.status_code == 202:
return RetVal(phantom.APP_SUCCESS, {})
return RetVal(action_result.set_status(phantom.APP_ERROR, "Empty response and no information in the header"),
None)
def _process_html_response(self, response, action_result):
""" This function is used to process html response.
:param response: response data
:param action_result: object of Action Result
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message)
"""
# An html response, treat it like an error
status_code = response.status_code
try:
soup = BeautifulSoup(response.text, "html.parser")
# Remove the script, style, footer and navigation part from the HTML message
for element in soup(["script", "style", "footer", "nav"]):
element.extract()
error_text = soup.text
split_lines = error_text.split('\n')
split_lines = [x.strip() for x in split_lines if x.strip()]
error_text = '\n'.join(split_lines)
except Exception:
error_text = "Cannot parse error details"
message = MS_AZURE_RESPONSE_ERROR_MESSAGE.format(status_code=status_code, error_text=error_text)
message = message.replace('{', '{{').replace('}', '}}')
if status_code == MS_AZURE_BAD_REQUEST_CODE:
message = MS_AZURE_RESPONSE_ERROR_MESSAGE.format(status_code=status_code, error_text=MS_AZURE_HTML_ERROR)
return RetVal(action_result.set_status(phantom.APP_ERROR, message), None)
def _process_json_response(self, response, action_result):
""" This function is used to process json response.
:param response: response data
:param action_result: object of Action Result
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message)
"""
# Try a json parse
try:
resp_json = response.json()
except Exception as e:
error_message = self._get_error_message_from_exception(e)
return RetVal(action_result.set_status(phantom.APP_ERROR, "Unable to parse JSON response. Error: {0}".
format(error_message)), None)
# Please specify the status codes here
if 200 <= response.status_code < 399:
return RetVal(phantom.APP_SUCCESS, resp_json)
error_message = response.text.replace('{', '{{').replace('}', '}}')
message = MS_AZURE_RESPONSE_ERROR_MESSAGE.format(status_code=response.status_code, error_text=error_message)
# Show only error message if available
if isinstance(resp_json.get('error', {}), dict):
if resp_json.get('error', {}).get('message'):
error_message = resp_json['error']['message']
message = MS_AZURE_RESPONSE_ERROR_MESSAGE.format(status_code=response.status_code, error_text=error_message)
else:
error_message = resp_json['error']
message = MS_AZURE_RESPONSE_ERROR_MESSAGE.format(status_code=response.status_code, error_text=error_message)
return RetVal(action_result.set_status(phantom.APP_ERROR, message), None)
def _process_response(self, response, action_result):
""" This function is used to process html response.
:param response: response data
:param action_result: object of Action Result
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message)
"""
# store the r_text in debug data, it will get dumped in the logs if the action fails
if hasattr(action_result, 'add_debug_data'):
action_result.add_debug_data({'r_status_code': response.status_code})
action_result.add_debug_data({'r_text': response.text})
action_result.add_debug_data({'r_headers': response.headers})
# Process each 'Content-Type' of response separately
# Process a json response
if 'json' in response.headers.get('Content-Type', ''):
return self._process_json_response(response, action_result)
if 'text/javascript' in response.headers.get('Content-Type', ''):
return self._process_json_response(response, action_result)
# Process an HTML response, Do this no matter what the API talks.
# There is a high chance of a PROXY in between SOAR and the rest of
# world, in case of errors, PROXY's return HTML, this function parses
# the error and adds it to the action_result.
if 'html' in response.headers.get('Content-Type', ''):
return self._process_html_response(response, action_result)
# Reset_password returns empty body
if not response.text and 200 <= response.status_code < 399:
return RetVal(phantom.APP_SUCCESS, {})
# it's not content-type that is to be parsed, handle an empty response
if not response.text:
return self._process_empty_response(response, action_result)
# everything else is actually an error at this point
response_content = response.text.replace('{', '{{').replace('}', '}}')
message = MS_AZURE_PROCESS_RESPONSE_ERROR_MESSAGE.format(status_code=response.status_code, content=response_content)
return RetVal(action_result.set_status(phantom.APP_ERROR, message), None)
def _get_asset_name(self, action_result):
""" Get name of the asset using SOAR URL.
:param action_result: object of ActionResult class
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message), asset name
"""
url = urlparse.urljoin(self.get_phantom_base_url(), f'rest/asset/{self._asset_id}')
ret_val, resp_json = self._make_rest_call(action_result=action_result, endpoint=url, verify=False) # nosemgrep
if phantom.is_fail(ret_val):
return ret_val, None
asset_name = resp_json.get('name')
if not asset_name:
return action_result.set_status(phantom.APP_ERROR, f'Asset Name for id: {self._asset_id} not found.'), None
return phantom.APP_SUCCESS, asset_name
def _get_external_phantom_base_url(self, action_result):
""" Get base url of SOAR.
:param action_result: object of ActionResult class
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message),
base url of SOAR
"""
url = urlparse.urljoin(self.get_phantom_base_url(), 'rest/system_info')
ret_val, resp_json = self._make_rest_call(action_result=action_result, endpoint=url, verify=False) # nosemgrep
if phantom.is_fail(ret_val):
return ret_val, None
phantom_base_url = resp_json.get('base_url').rstrip("/")
if not phantom_base_url:
return action_result.set_status(phantom.APP_ERROR, MS_AZURE_BASE_URL_NOT_FOUND_MESSAGE), None
return phantom.APP_SUCCESS, phantom_base_url
def _get_app_rest_url(self, action_result):
""" Get URL for making rest calls.
:param action_result: object of ActionResult class
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message),
URL to make rest calls
"""
ret_val, phantom_base_url = self._get_external_phantom_base_url(action_result)
if phantom.is_fail(ret_val):
return action_result.get_status(), None
ret_val, asset_name = self._get_asset_name(action_result)
if phantom.is_fail(ret_val):
return action_result.get_status(), None
self.save_progress(f'Using SOAR base URL: {phantom_base_url}')
app_json = self.get_app_json()
app_id = app_json['appid']
app_name = app_json['name']
app_dir_name = _get_dir_name_from_app_name(app_name)
url_to_app_rest = f"{phantom_base_url}/rest/handler/{app_dir_name}_{app_id}/{asset_name}"
return phantom.APP_SUCCESS, url_to_app_rest
def _make_rest_call(self, endpoint, action_result, verify=True, headers=None, params=None, data=None, json=None, method="get"):
""" Function that makes the REST call to the app.
:param endpoint: REST endpoint that needs to appended to the service address
:param action_result: object of ActionResult class
:param headers: request headers
:param params: request parameters
:param data: request body
:param json: JSON object
:param method: GET/POST/PUT/DELETE/PATCH (Default will be GET)
:param verify: verify server certificate (Default True)
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message),
response obtained by making an API call
"""
resp_json = None
try:
request_func = getattr(requests, method)
except AttributeError:
return RetVal(action_result.set_status(phantom.APP_ERROR, f"Invalid method: {method}"), resp_json)
try:
resp_json = request_func(endpoint, json=json, data=data, headers=headers, verify=verify, params=params, timeout=DEFAULT_TIMEOUT)
except Exception as e:
error_message = f"Error connecting to server. Details: {self._get_error_message_from_exception(e)}"
return RetVal(action_result.set_status(phantom.APP_ERROR, error_message), resp_json)
return self._process_response(resp_json, action_result)
def _make_rest_call_helper(self, action_result, endpoint, verify=True, headers=None, params=None, data=None, json=None, method="get"):
""" Function that helps setting REST call to the app.
:param endpoint: REST endpoint that needs to appended to the service address
:param action_result: object of ActionResult class
:param headers: request headers
:param params: request parameters
:param data: request body
:param json: JSON object
:param method: GET/POST/PUT/DELETE/PATCH (Default will be GET)
:param verify: verify server certificate (Default True)
:return: status phantom.APP_ERROR/phantom.APP_SUCCESS(along with appropriate message),
response obtained by making an API call
"""
url = f"{self._base_url}/{self._tenant}{endpoint}"
if headers is None:
headers = {}
token = self._state.get(MS_AZURE_TOKEN_STRING, {})
if not token.get(MS_AZURE_ACCESS_TOKEN_STRING):
ret_val = self._get_token(action_result)
if phantom.is_fail(ret_val):
return RetVal(action_result.get_status(), None)
headers.update({
'Authorization': f'Bearer {self._access_token}',
'Accept': 'application/json',
'Content-Type': 'application/json'
})
ret_val, resp_json = self._make_rest_call(url, action_result, verify, headers, params, data, json, method)
# If token is expired, generate a new token
message = action_result.get_message()
self.debug_print(f"message: {message}")
if message and ('token' in message and 'expired' in message):
self.save_progress("Token is invalid/expired. Hence, generating a new token.")
ret_val = self._get_token(action_result)
if phantom.is_fail(ret_val):
return RetVal(ret_val, None)
headers.update({'Authorization': f'Bearer {self._access_token}'})
ret_val, resp_json = self._make_rest_call(url, action_result, verify, headers, params, data, json, method)
if phantom.is_fail(ret_val):
return RetVal(ret_val, resp_json)
return RetVal(phantom.APP_SUCCESS, resp_json)
def _handle_generate_token(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
ret_val = self._get_token(action_result)
if phantom.is_fail(ret_val):
return action_result.get_status()
self._state['admin_consent'] = True
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS, "Token generated")
def _handle_test_connectivity(self, param):
""" Function that handles the test connectivity action, it is much simpler than other action handlers."""
# Progress
# self.save_progress("Generating Authentication URL")
app_state = {}
action_result = self.add_action_result(ActionResult(param))
if not (self._admin_access_required and self._admin_access_granted):
self.save_progress("Getting App REST endpoint URL")
# Get the URL to the app's REST Endpoint, this is the url that the TC dialog
# box will ask the user to connect to
ret_val, app_rest_url = self._get_app_rest_url(action_result)
if phantom.is_fail(ret_val):
self.save_progress(MS_REST_URL_NOT_AVAILABLE_MESSAGE.format(error=self.get_status()))
return self.set_status(phantom.APP_ERROR)
# create the url that the oauth server should re-direct to after the auth is completed
# (success and failure), this is added to the state so that the request handler will access
# it later on
redirect_uri = f"{app_rest_url}/result"
app_state['redirect_uri'] = redirect_uri
self.save_progress(MS_OAUTH_URL_MESSAGE)
self.save_progress(redirect_uri)
self._client_id = urlparse.quote(self._client_id)
self._tenant = urlparse.quote(self._tenant)
query_params = {
'client_id': self._client_id,
'redirect_uri': redirect_uri,
'state': self._asset_id,
}
if self._admin_access_required:
# Create the url for fetching administrator consent
admin_consent_url_base = MS_AZURE_ADMIN_CONSENT_URL.format(tenant_id=self._tenant)
else:
# Create the url authorization, this is the one pointing to the oauth server side
admin_consent_url_base = MS_AZURE_AUTHORIZE_URL.format(tenant_id=self._tenant)
query_params['scope'] = MS_AZURE_CODE_GENERATION_SCOPE
query_params['response_type'] = 'code'
query_string = '&'.join(f'{key}={value}' for key, value in query_params.items())
admin_consent_url = f'{admin_consent_url_base}?{query_string}'
app_state['admin_consent_url'] = admin_consent_url
# The URL that the user should open in a different tab.
# This is pointing to a REST endpoint that points to the app
url_to_show = f"{app_rest_url}/start_oauth?asset_id={self._asset_id}&"
# Save the state, will be used by the request handler
_save_app_state(app_state, self._asset_id, self)
self.save_progress('Please connect to the following URL from a different tab to continue the connectivity process')
self.save_progress(url_to_show)
self.save_progress(MS_AZURE_AUTHORIZE_TROUBLESHOOT_MESSAGE)
time.sleep(MS_AZURE_WAIT_FOR_URL_SLEEP)
completed = False
if not _is_valid_asset_id(self._asset_id):
return action_result.set_status(phantom.APP_ERROR, "Invalid asset id")
auth_status_file_path = _get_file_path(self._asset_id, is_state_file=False)
self.save_progress('Waiting for authorization to complete')
for i in range(0, 40):
self.send_progress('{0}'.format('.' * (i % 10)))
if auth_status_file_path.is_file():
completed = True
auth_status_file_path.unlink()
break
time.sleep(MS_TC_STATUS_SLEEP)
if not completed:
self.save_progress("Authentication process does not seem to be completed. Timing out")
self.save_progress(MS_AZURE_TEST_CONNECTIVITY_FAILURE_MESSAGE)
return self.set_status(phantom.APP_ERROR)
self.send_progress("")
# Load the state again, since the http request handlers would have saved the result of the admin consent
self._state = _load_app_state(self._asset_id, self)
# Deleting the local state file because of it replicates with actual state file while installing the app
current_file_path = pathlib.Path(__file__).resolve()
input_file = f'{self._asset_id}_state.json'
state_file_path = current_file_path.with_name(input_file)
state_file_path.unlink()
if not self._state:
self.save_progress(MS_STATE_FILE_ERROR_MESSAGE)
self.save_progress(MS_AZURE_TEST_CONNECTIVITY_FAILURE_MESSAGE)
return action_result.set_status(phantom.APP_ERROR)
self._state.setdefault('admin_consent', False)
if self._admin_access_required and not self._state.get('admin_consent'):
self.save_progress(MS_ADMIN_CONSENT_ERROR_MESSAGE)
self.save_progress(MS_AZURE_TEST_CONNECTIVITY_FAILURE_MESSAGE)
return action_result.set_status(phantom.APP_ERROR)
if not self._admin_access_required and not self._state.get('code'):
self.save_progress(MS_AUTHORIZATION_ERROR_MESSAGE)
self.save_progress(MS_AZURE_TEST_CONNECTIVITY_FAILURE_MESSAGE)
return action_result.set_status(phantom.APP_ERROR)
if self._admin_access_required:
self.save_progress("Admin consent received")
self.save_progress(
"Waiting for 30 seconds before generating token. If action fails with '403: AccessDenied' error, "
"please check permissions and re-run the 'test connectivity' after some time.")
self.save_progress(
"Admin consent is already received. You can mark 'Admin Consent Already Provided' to True, "
"unless you make changes in the permissions.")
time.sleep(30)
self.save_progress(MS_GENERATING_ACCESS_TOKEN_MESSAGE)
ret_val = self._get_token(action_result)
if phantom.is_fail(ret_val):
return action_result.get_status()
self.save_progress("Getting info about a single user to verify token")
params = {'$top': '1'}
ret_val, response = self._make_rest_call_helper(action_result, "/users", params=params)
if phantom.is_fail(ret_val):
self.save_progress("API to get users failed")
self.save_progress(MS_AZURE_TEST_CONNECTIVITY_FAILURE_MESSAGE)
return self.set_status(phantom.APP_ERROR)
value = response.get('value')
if value:
self.save_progress("Got user info")
self.save_progress(MS_AZURE_TEST_CONNECTIVITY_PASSED)
return self.set_status(phantom.APP_SUCCESS)
def _handle_list_users(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
filter_string = param.get('filter_string')
select_string = param.get('select_string')
expand_string = param.get('expand_string')
use_advanced_query = param.get('use_advanced_query')
headers = {}
parameters = {}
if filter_string:
parameters['$filter'] = filter_string
if select_string:
select_string = [param_value.strip() for param_value in select_string.split(",")]
select_string = list(filter(None, select_string))
parameters['$select'] = ','.join(param_value for param_value in select_string)
if expand_string:
parameters['$expand'] = expand_string
if use_advanced_query:
headers['ConsistencyLevel'] = 'eventual'
parameters['$count'] = 'true'
ret_val = self._handle_pagination(action_result, '/users', headers=headers, params=parameters)
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
resp_data = action_result.get_data()
if resp_data and resp_data[action_result.get_data_size() - 1] == 'Empty response':
summary['num_users'] = (action_result.get_data_size()) - 1
else:
summary['num_users'] = action_result.get_data_size()
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_reset_password(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
user_id = param['user_id']
temp_password = param.get('temp_password', '')
force_change = param.get('force_change', True)
data = {
'passwordProfile': {
'forceChangePasswordNextSignIn': force_change,
'password': temp_password
}
}
endpoint = f'/users/{user_id}'
ret_val, _ = self._make_rest_call_helper(action_result, endpoint, json=data, method='patch')
if phantom.is_fail(ret_val):
return ret_val
summary = action_result.update_summary({})
summary['status'] = f"Successfully reset password for {user_id}"
# An empty response indicates success. No response body is returned.
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_enable_user(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
user_id = param['user_id']
data = {
"accountEnabled": True
}
endpoint = f'/users/{user_id}'
ret_val, _ = self._make_rest_call_helper(action_result, endpoint, json=data, method='patch')
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
summary['status'] = f"Successfully enabled user {user_id}"
# An empty response indicates success. No response body is returned.
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_invalidate_tokens(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
user_id = param['user_id']
endpoint = f'/users/{user_id}/revokeSignInSessions'
ret_val, _ = self._make_rest_call_helper(action_result, endpoint, method='post')
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
summary['status'] = "Successfully disabled tokens"
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_disable_user(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
user_id = param['user_id']
data = {
"accountEnabled": False
}
endpoint = f'/users/{user_id}'
ret_val, _ = self._make_rest_call_helper(action_result, endpoint, json=data, method='patch')
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
summary['status'] = f"Successfully disabled user {user_id}"
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_list_user_attributes(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
user_id = param.get('user_id')
select_string = param.get('select_string')
expand_string = param.get('expand_string')
use_advanced_query = param.get('use_advanced_query')
headers = {}
parameters = {}
if select_string:
select_string = [param_value.strip() for param_value in select_string.split(",")]
select_string = list(filter(None, select_string))
parameters['$select'] = ','.join(param_value for param_value in select_string)
if expand_string:
parameters['$expand'] = expand_string
if use_advanced_query:
headers['ConsistencyLevel'] = 'eventual'
parameters['$count'] = 'true'
if user_id:
endpoint = f'/users/{user_id}'
else:
endpoint = '/users'
ret_val = self._handle_pagination(action_result, endpoint, headers=headers, params=parameters)
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
if user_id:
summary['status'] = f"Successfully retrieved attributes for user {user_id}"
else:
summary['status'] = "Successfully retrieved user attributes"
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_list_user_devices(self, param):
self.save_progress("In action handler for: {0}".format(self.get_action_identifier()))
action_result = self.add_action_result(ActionResult(dict(param)))
user_id = param['user_id']
parameters = {}
select_string = param.get('select_string')
if select_string:
select_string = [param_value.strip() for param_value in select_string.split(",")]
select_string = list(filter(None, select_string))
parameters['$select'] = ','.join(param_value for param_value in select_string)
endpoint = f'/users/{user_id}/ownedDevices'
ret_val = self._handle_pagination(action_result, endpoint, params=parameters)
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
summary['status'] = "Successfully retrieved owned devices for user {}".format(user_id)
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_set_user_attribute(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
user_id = param['user_id']
attribute = param['attribute']
attribute_value = param['attribute_value']
data = {
attribute: attribute_value
}
endpoint = f'/users/{user_id}'
ret_val, _ = self._make_rest_call_helper(action_result, endpoint, json=data, method='patch')
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
summary['status'] = "Successfully updated user attribute"
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_add_user(self, param):
config = self.get_config()
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
object_id = param['group_object_id']
user_id = param['user_id']
data = {
'@odata.id': "https://{}/directoryObjects/{}".format(MSADGRAPH_API_REGION[config.get(MS_AZURE_URL, "Global")], user_id)
}
endpoint = f'/groups/{object_id}/members/$ref'
ret_val, _ = self._make_rest_call_helper(action_result, endpoint, json=data, method='post')
summary = action_result.update_summary({})
if phantom.is_fail(ret_val):
message = action_result.get_message()
if 'references already exist for the following modified properties: \'members\'.' in message:
summary['status'] = "User already in group"
return action_result.get_status()
else:
return ret_val
else:
summary['status'] = "Successfully added user to group"
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_remove_user(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
object_id = param['group_object_id']
user_id = param['user_id']
endpoint = f'/groups/{object_id}/members/{user_id}/$ref'
ret_val, _ = self._make_rest_call_helper(action_result, endpoint, method='delete')
summary = action_result.update_summary({})
if phantom.is_fail(ret_val):
message = action_result.get_message()
if 'does not exist or one of its queried' in message:
summary['status'] = "User not in group"
return action_result.get_status()
else:
summary['status'] = "Successfully removed user from group"
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_list_groups(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
filter_string = param.get('filter_string')
select_string = param.get('select_string')
expand_string = param.get('expand_string')
use_advanced_query = param.get('use_advanced_query')
headers = {}
parameters = {}
if filter_string:
parameters['$filter'] = filter_string
if select_string:
select_string = [param_value.strip() for param_value in select_string.split(",")]
select_string = list(filter(None, select_string))
parameters['$select'] = ','.join(param_value for param_value in select_string)
if expand_string:
parameters['$expand'] = expand_string
if use_advanced_query:
headers['ConsistencyLevel'] = 'eventual'
parameters['$count'] = 'true'
ret_val = self._handle_pagination(action_result, '/groups', headers=headers, params=parameters)
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
resp_data = action_result.get_data()
if resp_data and resp_data[action_result.get_data_size() - 1] == 'Empty response':
summary['num_groups'] = (action_result.get_data_size()) - 1
else:
summary['num_groups'] = action_result.get_data_size()
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_get_group(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
select_string = param.get('select_string')
expand_string = param.get('expand_string')
use_advanced_query = param.get('use_advanced_query')
headers = {}
parameters = {}
if select_string:
select_string = [param_value.strip() for param_value in select_string.split(",")]
select_string = list(filter(None, select_string))
parameters['$select'] = ','.join(param_value for param_value in select_string)
if expand_string:
parameters['$expand'] = expand_string
if use_advanced_query:
headers['ConsistencyLevel'] = 'eventual'
parameters['$count'] = 'true'
object_id = param['object_id']
endpoint = f'/groups/{object_id}'
ret_val, response = self._make_rest_call_helper(action_result, endpoint, method='get', headers=headers, params=parameters)
if phantom.is_fail(ret_val):
return action_result.get_status()
action_result.add_data(response)
summary = action_result.update_summary({})
summary['status'] = f"Successfully retrieved group {object_id}"
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_list_group_members(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
object_id = param['group_object_id']
select_string = param.get('select_string')
expand_string = param.get('expand_string')
use_advanced_query = param.get('use_advanced_query')
headers = {}
parameters = {}
if select_string:
select_string = [param_value.strip() for param_value in select_string.split(",")]
select_string = list(filter(None, select_string))
parameters['$select'] = ','.join(param_value for param_value in select_string)
if expand_string:
parameters['$expand'] = expand_string
if use_advanced_query:
headers['ConsistencyLevel'] = 'eventual'
parameters['$count'] = 'true'
endpoint = f'/groups/{object_id}/members'
ret_val = self._handle_pagination(action_result, endpoint, headers=headers, params=parameters)
if phantom.is_fail(ret_val):
return action_result.get_status()
summary = action_result.update_summary({})
summary['num_users'] = action_result.get_data_size()
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_list_directory_roles(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
endpoint = '/directoryRoles'
ret_val, response = self._make_rest_call_helper(action_result, endpoint, method='get')
if phantom.is_fail(ret_val):
return action_result.get_status()
value = response.get('value', [])
for item in value:
action_result.add_data(item)
summary = action_result.update_summary({})
summary['num_directory_roles'] = len(value)
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS)
def _handle_validate_group(self, param):
self.save_progress(f"In action handler for: {self.get_action_identifier()}")
action_result = self.add_action_result(ActionResult(dict(param)))
object_id = param['group_object_id']
user_id = param['user_id']
endpoint = f'/users/{user_id}/memberOf?$filter=id eq \'{object_id}\''
ret_val, response = self._make_rest_call_helper(action_result, endpoint, method='get')
if phantom.is_fail(ret_val):
return action_result.get_status()
user_id_map = {}
for user in response.get('value', []):
user_id_map[user['id']] = user['displayName']
self.save_progress(f"Completed action handler for: {self.get_action_identifier()}")
return action_result.set_status(phantom.APP_SUCCESS, f"User is member of group: {ret_val}")
def _get_token(self, action_result):
""" This function is used to get a token via REST Call.
:param action_result: Object of action result
:return: status(phantom.APP_SUCCESS/phantom.APP_ERROR)
"""
data = {
'client_id': self._client_id,
'client_secret': self._client_secret,
}
req_url = SERVER_TOKEN_URL.format(self._tenant)
headers = {
'Content-Type': 'application/x-www-form-urlencoded'
}
if not self._admin_access_required:
data['scope'] = MS_AZURE_CODE_GENERATION_SCOPE
data['redirect_uri'] = self._state.get('redirect_uri')
auth_code = self._state.get('code', None)
if self._state.get(MS_AZURE_TOKEN_STRING, {}).get(MS_AZURE_REFRESH_TOKEN_STRING, None):
data['refresh_token'] = self._refresh_token
data['grant_type'] = 'refresh_token'
elif auth_code:
data['code'] = auth_code
data['grant_type'] = 'authorization_code'
else:
return action_result.set_status(phantom.APP_ERROR, "Unexpected details retrieved from the state file.")
else:
data['scope'] = 'https://graph.microsoft.com/.default'
data['grant_type'] = 'client_credentials'
ret_val, resp_json = self._make_rest_call(req_url, action_result, headers=headers, data=data, method='post')
if phantom.is_fail(ret_val):
return action_result.get_status()
if self._admin_access_required and self._admin_access_granted:
self._state['admin_consent'] = True
self._state[MS_AZURE_TOKEN_STRING] = resp_json
self._access_token = resp_json.get(MS_AZURE_ACCESS_TOKEN_STRING, None)
self._refresh_token = resp_json.get(MS_AZURE_REFRESH_TOKEN_STRING, None)
return phantom.APP_SUCCESS
def _handle_pagination(self, action_result, endpoint, headers=None, params=None):
"""
This action is used to create an iterator that will paginate through responses from called methods.
:param action_result: Object of ActionResult class
:param endpoint: REST endpoint that needs to appended to the service address
:param headers: Dictionary of headers for the rest API calls
:param params: Dictionary of params for the rest API calls
"""
# maximum page size
page_size = MS_AZURE_PAGE_SIZE
if isinstance(params, dict):
params.update({"$top": page_size})
else:
params = {"$top": page_size}
while True:
# make rest call
ret_val, response = self._make_rest_call_helper(action_result, endpoint, headers=headers, params=params, method='get')
if phantom.is_fail(ret_val):
return None
if "value" in response:
for user in response.get('value', []):
action_result.add_data(user)
if len(response.get('value')) > 0 and response.get('value')[0] == {}:
action_result.add_data('Empty response')
else:
action_result.add_data(response)
if response.get(MS_AZURE_NEXT_LINK_STRING):
parsed_url = urlparse.urlparse(response.get(MS_AZURE_NEXT_LINK_STRING))
try:
params['$skiptoken'] = urlparse.parse_qs(parsed_url.query).get('$skiptoken')[0]
except Exception:
self.debug_print(f"odata.nextLink is {response.get(MS_AZURE_NEXT_LINK_STRING)}")
self.debug_print("Error occurred while extracting skiptoken from the odata.nextLink")
break
else:
break
return phantom.APP_SUCCESS
def handle_action(self, param):
ret_val = phantom.APP_SUCCESS
# Get the action that we are supposed to execute for this App Run
action_id = self.get_action_identifier()
self.debug_print("action_id", self.get_action_identifier())
if action_id == 'test_connectivity':
ret_val = self._handle_test_connectivity(param)
elif action_id == 'list_users':
ret_val = self._handle_list_users(param)
elif action_id == 'reset_password':
ret_val = self._handle_reset_password(param)
elif action_id == 'invalidate_tokens':
ret_val = self._handle_invalidate_tokens(param)
elif action_id == 'enable_user':
ret_val = self._handle_enable_user(param)
elif action_id == 'disable_user':
ret_val = self._handle_disable_user(param)
elif action_id == 'list_user_attributes':
ret_val = self._handle_list_user_attributes(param)
elif action_id == 'set_user_attribute':
ret_val = self._handle_set_user_attribute(param)
elif action_id == 'remove_user':
ret_val = self._handle_remove_user(param)
elif action_id == 'add_user':
ret_val = self._handle_add_user(param)
elif action_id == 'list_groups':
ret_val = self._handle_list_groups(param)
elif action_id == 'get_group':
ret_val = self._handle_get_group(param)
elif action_id == 'list_group_members':
ret_val = self._handle_list_group_members(param)
elif action_id == 'validate_group':
ret_val = self._handle_validate_group(param)
elif action_id == 'list_directory_roles':
ret_val = self._handle_list_directory_roles(param)
elif action_id == 'generate_token':
ret_val = self._handle_generate_token(param)
elif action_id == 'list_user_devices':
ret_val = self._handle_list_user_devices(param)
return ret_val
def initialize(self):
""" This is an optional function that can be implemented by the AppConnector derived class. Since the
configuration dictionary is already validated by the time this function is called, it's a good place to do any
extra initialization of any internal modules. This function MUST return a value of either phantom.APP_SUCCESS or
phantom.APP_ERROR. If this function returns phantom.APP_ERROR, then AppConnector::handle_action will not get
called.
"""
self._state = self.load_state()
# get the asset config
config = self.get_config()
self._asset_id = self.get_asset_id()
self._tenant = config[MS_AZURE_CONFIG_TENANT]
self._client_id = config[MS_AZURE_CONFIG_CLIENT_ID]
self._client_secret = config[MS_AZURE_CONFIG_CLIENT_SECRET]
self._admin_access_required = config.get(MS_AZURE_CONFIG_ADMIN_ACCESS_REQUIRED, False)
self._admin_access_granted = config.get(MS_AZURE_CONFIG_ADMIN_ACCESS_GRANTED, False)
self._access_token = self._state.get(MS_AZURE_TOKEN_STRING, {}).get(MS_AZURE_ACCESS_TOKEN_STRING)
self._refresh_token = self._state.get(MS_AZURE_TOKEN_STRING, {}).get(MS_AZURE_REFRESH_TOKEN_STRING)
self._base_url = MSADGRAPH_API_URLS[config.get(MS_AZURE_URL, "Global")]
return phantom.APP_SUCCESS
def finalize(self):
# Save the state, this data is saved across actions and app upgrades
self.save_state(self._state)
return phantom.APP_SUCCESS
if __name__ == '__main__':
import argparse
import pudb
pudb.set_trace()
argparser = argparse.ArgumentParser()
argparser.add_argument('input_test_json', help='Input Test JSON file')
argparser.add_argument('-u', '--username', help='username', required=False)
argparser.add_argument('-p', '--password', help='password', required=False)
argparser.add_argument('-v', '--verify', action='store_true', help='verify', required=False, default=False)
args = argparser.parse_args()
session_id = None
username = args.username
password = args.password
verify = args.verify
if username is not None and password is None:
# User specified a username but not a password, so ask
import getpass
password = getpass.getpass("Password: ")
if username and password:
login_url = BaseConnector._get_phantom_base_url() + "login"
try:
print("Accessing the Login page")
r = requests.get(login_url, verify=verify, timeout=60)
csrftoken = r.cookies['csrftoken']
data = dict()
data['username'] = username
data['password'] = password
data['csrfmiddlewaretoken'] = csrftoken
headers = dict()
headers['Cookie'] = 'csrftoken=' + csrftoken
headers['Referer'] = login_url
print("Logging into Platform to get the session id")
r2 = requests.post(login_url, verify=verify, data=data, headers=headers, timeout=60)
session_id = r2.cookies['sessionid']
except Exception as e:
print("Unable to get session id from the platform. Error: " + str(e))
sys.exit(1)
with open(args.input_test_json) as f:
in_json = f.read()
in_json = json.loads(in_json)
print(json.dumps(in_json, indent=4))
connector = MSADGraphConnector()
connector.print_progress_message = True
if session_id is not None:
in_json['user_session_token'] = session_id
connector._set_csrf_info(csrftoken, headers['Referer'])
ret_val = connector._handle_action(json.dumps(in_json), None)
print(json.dumps(json.loads(ret_val), indent=4))
sys.exit(0)