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.
Splunk_Deploiement/apps/trackme/bin/trackmesplksoar.py

812 lines
32 KiB

#!/usr/bin/env python
# coding=utf-8
__author__ = "TrackMe Limited"
__copyright__ = "Copyright 2022-2026, TrackMe Limited, U.K."
__credits__ = "TrackMe Limited, U.K."
__license__ = "TrackMe Limited, all rights reserved"
__version__ = "0.1.0"
__maintainer__ = "TrackMe Limited, U.K."
__email__ = "support@trackme-solutions.com"
__status__ = "PRODUCTION"
# Built-in libraries
import json
import logging
import os
import sys
import time
from ast import literal_eval
# Third-party libraries
import requests
import urllib3
# Logging handlers
from logging.handlers import RotatingFileHandler
# Disable insecure request warnings for urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# splunk home
splunkhome = os.environ["SPLUNK_HOME"]
# set logging
filehandler = RotatingFileHandler(
"%s/var/log/splunk/trackme_splk_soar.log" % splunkhome,
mode="a",
maxBytes=10000000,
backupCount=1,
)
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(filename)s %(funcName)s %(lineno)d %(message)s"
)
logging.Formatter.converter = time.gmtime
filehandler.setFormatter(formatter)
log = logging.getLogger() # root logger - Good to get it only once.
for hdlr in log.handlers[:]: # remove the existing file handlers
if isinstance(hdlr, logging.FileHandler):
log.removeHandler(hdlr)
log.addHandler(filehandler) # set the new handler
# set the log level to INFO, DEBUG as the default is ERROR
log.setLevel(logging.INFO)
# append current directory
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# import libs
import import_declare_test
# Import Splunk libs
from splunklib.searchcommands import (
dispatch,
GeneratingCommand,
Configuration,
Option,
validators,
)
# Import trackme libs
from trackme_libs import trackme_reqinfo
@Configuration(distributed=False)
class TrackMeRestHandler(GeneratingCommand):
soar_server = Option(
doc="""
**Syntax:** **The soar_server=****
**Description:** Mandatory, SOAR server account as configured in the Splunk App for SOAR""",
require=False,
default="*",
validate=validators.Match("url", r"^.*"),
)
action = Option(
doc="""
**Syntax:** **The action to be requested=****
**Description:** A pre-built set of actions or a single action, valid options: soar_get, soar_post, soar_test_apps, soar_automation_broker_manage""",
require=False,
default="soar_get",
validate=validators.Match(
"action",
r"^(?:soar_get|soar_post|soar_test_apps|soar_health_status|soar_health_memory|soar_health_load|soar_automation_broker_manage|soar_playbook_status)$",
),
)
action_data = Option(
doc="""
**Syntax:** **The data body for the actions to be requested=****
**Description:** Some actions may accept options, this should be a JSON formated object""",
require=False,
default=None,
validate=validators.Match("action_data", r"^\{.*\}$"),
)
action_params = Option(
doc="""
**Syntax:** **Optional extra params for the actions to be requested=****
**Description:** In some cases, you way want to add extra params to the query, this should be a JSON formated object""",
require=False,
default=None,
validate=validators.Match("action_params", r"^\{.*\}$"),
)
def generate(self, **kwargs):
# Start performance counter
start = time.time()
# Get request info and set logging level
reqinfo = trackme_reqinfo(
self._metadata.searchinfo.session_key, self._metadata.searchinfo.splunkd_uri
)
log.setLevel(reqinfo["logging_level"])
# Get the session key
session_key = self._metadata.searchinfo.session_key
# Build header and target
header = f"Splunk {session_key}"
root_url = f"{reqinfo['server_rest_uri']}/services/trackme/v2/splk_soar"
# Set HTTP headers
headers = {"Authorization": header}
headers["Content-Type"] = "application/json"
# load action_data, if any
if self.action_data:
if not isinstance(self.action_data, dict):
try:
# Try parsing as standard JSON (with double quotes)
action_data = json.loads(self.action_data)
except ValueError:
# If it fails, try parsing with ast.literal_eval (supports single quotes)
action_data = literal_eval(self.action_data)
else:
action_data = None
# load action_params, if any
if self.action_params:
if not isinstance(self.action_params, dict):
try:
# Try parsing as standard JSON (with double quotes)
action_params = json.loads(self.action_params)
except ValueError:
# If it fails, try parsing with ast.literal_eval (supports single quotes)
action_params = literal_eval(self.action_params)
else:
action_params = None
# Set session and proceed
with requests.Session() as session:
#
# Active SOAR test apps connectivity
#
if self.action == "soar_test_apps":
target_url = f"{root_url}/admin/soar_test_assets"
if not action_data:
active_check = "True"
assets_allow_list = "None"
assets_block_list = "None"
else:
# active check, if defined, otherwise defaults to True
active_check = action_data.get("active_check", "True")
# get assets_allow_list, if any
assets_allow_list = action_data.get("assets_allow_list", "None")
# get assets_block_list, if any
assets_block_list = action_data.get("assets_block_list", "None")
data = {
"soar_server": self.soar_server,
"active_check": active_check,
"assets_allow_list": assets_allow_list,
"assets_block_list": assets_block_list,
}
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code == 200: # we stricly expect a 200 HTTP code
response_json = response.json() # our response is always json
response_soar = response_json.get(
"response"
) # get response inside response
logging.debug(
f'response_soar="{json.dumps(response_soar)}"'
) # debug
for el in response_soar: # loop and parse
yield {
"_time": el.get("mtime_epoch"),
"_raw": el,
"id": el.get("id"),
"message": el.get("message"),
"time": el.get("mtime_human"),
"name": el.get("name"),
"status": el.get("status"),
"type": el.get("type"),
}
else:
error_msg = f'request has failed, response.status_code="{response.status_code}", response.text="{response.text}"'
logging.error(error_msg)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
#
# SOAR health services status
#
elif self.action == "soar_health_status":
target_url = f"{root_url}/soar_get_endpoint"
data = {"soar_server": self.soar_server, "endpoint": "health"}
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code == 200: # we stricly expect a 200 HTTP code
response_json = response.json() # our response is always json
response_soar = response_json.get(
"response"
) # get response inside response
logging.debug(
f'response_soar="{json.dumps(response_soar)}"'
) # debug
# extract and render health services status
health_status = response_soar.get("status")
for el in health_status:
yield {
"_time": time.time(),
"_raw": {el: health_status.get(el)},
"service": el,
"status": health_status.get(el),
}
else:
error_msg = f'request has failed, response.status_code="{response.status_code}", response.text="{response.text}"'
logging.error(error_msg)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
#
# SOAR health memory usage
#
elif self.action == "soar_health_memory":
target_url = f"{root_url}/soar_get_endpoint"
data = {"soar_server": self.soar_server, "endpoint": "health"}
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code == 200: # we stricly expect a 200 HTTP code
response_json = response.json() # our response is always json
response_soar = response_json.get(
"response"
) # get response inside response
logging.debug(
f'response_soar="{json.dumps(response_soar)}"'
) # debug
# extract and render health services status
health_memory = response_soar.get("memory_data")
# write our own
mem_free = None
mem_used = None
mem_cached = None
for el in health_memory: # this is a list
if el.get("label") == "Free":
mem_free = int(el.get("value"))
if el.get("label") == "Used":
mem_used = int(el.get("value"))
if el.get("label") == "Cached":
mem_cached = int(el.get("value"))
# we can calculate things!
mem_total = mem_used + mem_free
mem_used_pct = round(mem_used / mem_total * 100, 2)
mem_cached_pct = round(mem_cached / mem_total * 100, 2)
mem_summary = {
"mem_free": mem_free,
"mem_used": mem_used,
"mem_cached": mem_cached,
"mem_used_pct": mem_used_pct,
"mem_cached_pct": mem_cached_pct,
}
yield {
"_time": time.time(),
"_raw": mem_summary,
"mem_free": mem_free,
"mem_used": mem_used,
"mem_cached": mem_cached,
"mem_used_pct": mem_used_pct,
"mem_cached_pct": mem_cached_pct,
}
else:
error_msg = f'request has failed, response.status_code="{response.status_code}", response.text="{response.text}"'
logging.error(error_msg)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
#
# SOAR health cpu load
#
elif self.action == "soar_health_load":
target_url = f"{root_url}/soar_get_endpoint"
data = {"soar_server": self.soar_server, "endpoint": "health"}
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code == 200: # we stricly expect a 200 HTTP code
response_json = response.json() # our response is always json
response_soar = response_json.get(
"response"
) # get response inside response
logging.debug(
f'response_soar="{json.dumps(response_soar)}"'
) # debug
# extract and render health services status
health_load = response_soar.get("load_data")
load_summary = {}
count = 0
for (
el
) in (
health_load
): # this is a list, load come in order from 1 to 15min
count += 1
if count == 1:
load_summary["load_1min"] = el.get("load")
if count == 2:
load_summary["load_5min"] = el.get("load")
if count == 3:
load_summary["load_15min"] = el.get("load")
yield {
"_time": time.time(),
"_raw": load_summary,
"load_1min": load_summary.get("load_1min"),
"load_5min": load_summary.get("load_5min"),
"load_15min": load_summary.get("load_15min"),
}
else:
error_msg = f'request has failed, response.status_code="{response.status_code}", response.text="{response.text}"'
logging.error(error_msg)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
#
# get query against a SOAR endpoint
#
elif self.action == "soar_get":
target_url = f"{root_url}/soar_get_endpoint"
data = {
"soar_server": self.soar_server,
"endpoint": action_data.get("endpoint"),
}
# if extra params are provided, add them to the data
if action_params:
data["params"] = action_params
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code == 200: # we stricly expect a 200 HTTP code
response_json = response.json() # our response is always json
response_soar = response_json.get(
"response"
) # get response inside response
logging.debug(
f'response_soar="{json.dumps(response_soar)}"'
) # debug
if isinstance(response_soar, dict):
yield {
"_time": time.time(),
"_raw": response_soar,
}
elif isinstance(response_soar, list):
if len(response_soar) > 0: # if not an empty list
for el in response_soar: # this is a list
yield {
"_time": time.time(),
"_raw": el,
}
else:
yield {
"_time": time.time(),
"_raw": {
"response": "REST API call was successful, but an empty response was received.",
"response.status_code": response.status_code,
"response.text": response.text,
}, # return index as key and element as value
}
else: # something else
yield {
"_time": time.time(),
"_raw": response_soar,
}
else:
error_msg = f'request has failed, response.status_code="{response.status_code}", response.text="{response.text}"'
logging.error(error_msg)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
#
# post query against a SOAR endpoint
#
elif self.action == "soar_post":
target_url = f"{root_url}/admin/soar_post_endpoint"
data = {
"soar_server": self.soar_server,
"endpoint": action_data.get("endpoint"),
"data": action_data.get("data"),
}
# if extra params are provided, add them to the data
if action_params:
data["params"] = action_params
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code == 200: # we stricly expect a 200 HTTP code
response_json = response.json() # our response is always json
response_soar = response_json.get(
"response"
) # get response inside response
logging.debug(
f'response_soar="{json.dumps(response_soar)}"'
) # debug
if isinstance(response_soar, list): # if the response is a list
if len(response_soar) > 0: # if response if not empty list
for idx, el in enumerate(
response_soar
): # enumerate to keep track of index
yield {
"_time": time.time(),
"_raw": {
idx: el
}, # return index as key and element as value
}
else:
yield {
"_time": time.time(),
"_raw": {
"response": "REST API call was successful, but an empty response was received, this can be expected if there were no operations to be performed.",
"response.status_code": response.status_code,
"response.text": response.text,
}, # return index as key and element as value
}
elif isinstance(
response_soar, dict
): # if the response is a dictionary
yield {
"_time": time.time(),
"_raw": response_soar,
}
else: # if the response is neither a list nor a dictionary
logging.error(
f"Unexpected response_soar type: {type(response_soar)}"
)
else:
error_msg = f'request has failed, response.status_code="{response.status_code}", response.text="{response.text}"'
logging.error(error_msg)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
#
# Automation Broker advanced management
#
elif self.action == "soar_automation_broker_manage":
target_url = f"{root_url}/admin/soar_automation_broker_manage"
data = {
"soar_server": self.soar_server,
}
# optional
if action_data:
mode = action_data.get("mode", None)
if mode:
data["mode"] = mode
#
# pool definition
#
automation_brokers_pool_members = action_data.get(
"automation_brokers_pool_members", None
)
if automation_brokers_pool_members:
data["automation_brokers_pool_members"] = (
automation_brokers_pool_members
)
#
# active1/active2, these options are deprecated and left for compatibility purposes
#
automation_active1_broker_name = action_data.get(
"automation_active1_broker_name", None
)
if automation_active1_broker_name:
data["automation_active1_broker_name"] = (
automation_active1_broker_name
)
automation_active2_broker_name = action_data.get(
"automation_active2_broker_name", None
)
if automation_active2_broker_name:
data["automation_active2_broker_name"] = (
automation_active2_broker_name
)
assets_update_forbidden_fields = action_data.get(
"assets_update_forbidden_fields", None
)
if assets_update_forbidden_fields:
data["assets_update_forbidden_fields"] = (
assets_update_forbidden_fields
)
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code == 200: # we stricly expect a 200 HTTP code
response_json = response.json() # our response is always json
response_soar = response_json.get(
"response"
) # get response inside response
logging.debug(
f'response_soar="{json.dumps(response_soar)}"'
) # debug
if isinstance(response_soar, list): # if the response is a list
if len(response_soar) > 0: # if response if not empty list
for el in response_soar:
yield {
"_time": time.time(),
"_raw": el,
}
else:
yield {
"_time": time.time(),
"_raw": {
"response": "REST API call was successful, but an empty response was received, this can be expected if there were no operations to be performed.",
"response.status_code": response.status_code,
"response.text": response.text,
}, # return index as key and element as value
}
elif isinstance(
response_soar, dict
): # if the response is a dictionary
yield {
"_time": time.time(),
"_raw": response_soar,
}
else: # if the response is neither a list nor a dictionary
logging.error(
f"Unexpected response_soar type: {type(response_soar)}"
)
else:
error_msg = f'request has failed, response.status_code="{response.status_code}", response.text="{response.text}"'
logging.error(error_msg)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
#
# Playbook status
#
elif self.action == "soar_playbook_status":
from datetime import datetime, timedelta, timezone
target_url = f"{root_url}/soar_get_endpoint"
# Set default page_size
page_size = 1000
# Override page_size if specified in action_params
if action_params and "page_size" in action_params:
try:
page_size = int(action_params.get("page_size"))
logging.debug(f"Using custom page_size: {page_size}")
except (ValueError, TypeError) as e:
logging.error(
f"Invalid page_size value in action_params: {str(e)}"
)
# Keep default page_size if invalid value provided
# Set default max_age_sec
max_age_sec = 300
# Override max_age_sec if specified in action_params
if action_params and "max_age_sec" in action_params:
try:
max_age_sec = int(action_params.get("max_age_sec"))
logging.debug(f"Using custom max_age_sec: {max_age_sec}")
except (ValueError, TypeError) as e:
logging.error(
f"Invalid max_age_sec value in action_params: {str(e)}"
)
# Keep default max_age_sec if invalid value provided
# define cutoff time
cutoff_time = datetime.now(timezone.utc) - timedelta(
seconds=max_age_sec
)
filter_update_time_gt = (
f"\"{cutoff_time.strftime('%Y-%m-%dT%H:%M:%SZ')}\""
)
logging.info(
f"Applying max_age_sec filter: update_time > {filter_update_time_gt}"
)
# init page
page = 0
# Start with known statuses
known_statuses = [
"success",
"failed",
"running",
"pending",
"cancelled",
"waiting",
]
status_summary = {status: 0 for status in known_statuses}
# process loop
while True:
params = {"page_size": page_size, "page": page}
if filter_update_time_gt:
params["_filter_update_time__gt"] = filter_update_time_gt
data = {
"soar_server": self.soar_server,
"endpoint": "playbook_run",
"params": params,
}
response = session.post(
target_url, headers=headers, verify=False, data=json.dumps(data)
)
if response.status_code != 200:
logging.error(
f"Playbook run fetch failed on page {page}: {response.status_code}, {response.text}"
)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
},
"action": "failed",
"response": response.text,
"http_status_code": response.status_code,
}
return
response_json = response.json()
response_data = response_json.get("response", [])
if not isinstance(response_data, list):
logging.error(
f"Unexpected response format: {type(response_data)}"
)
yield {
"_time": time.time(),
"_raw": {
"action": "failed",
"response": response_json,
"error": "Unexpected response structure (not a list)",
},
}
return
if not response_data:
break # No more data
for pb in response_data:
status = pb.get("status", "unknown")
if status not in status_summary:
status_summary[status] = 0 # Dynamically track unknowns
status_summary[status] += 1
if len(response_data) < page_size:
break
page += 1
yield {"_time": time.time(), "_raw": status_summary, **status_summary}
# Log the run time
logging.info(
f"trackmesoar has terminated, run_time={round(time.time() - start, 3)}"
)
dispatch(TrackMeRestHandler, sys.argv, sys.stdin, sys.stdout, __name__)