"""
Countermeasures driver program - executes one or more countermeasures
Copyright 2023 Fortinet, Inc. All Rights Reserved.
[email protected]
TODO:
- global block file to prevent all countermeasures from running?
- sudo access validation
- run time limits
"""
try:
import argparse
except:
import p_argparse as argparse
try:
# Python 2.x
import ConfigParser as configparser
except:
# Python 3.x
import configparser
try:
import json
except ImportError:
import simplejson as json
import p_importlib
import logging
import logging.handlers
import os
import os.path
import subprocess
import sys
import tarfile
import tempfile
import time
import traceback
import types
from datetime import datetime
try:
# Python 2.x
import urllib2
except:
import urllib.request as urllib2
try:
# Python 2.x
import urlparse
except:
import urllib.parse as urlparse
import zipfile
import aggregator
import agent_util
from .plugins.CountermeasurePlugin import JsonPlugin
MAX_OUTPUT_LENGTH = 100 * 1024
# Backported for Python 2.4 support
def any(iterable):
for element in iterable:
if element:
return True
return False
def load_plugins():
"""
Dynamically load all available Countermeasure plugins, in both the application default
and the customer's local custom plugin directory
"""
log = setup_logging("countermeasure")
plugins = {}
for directory in (os.path.join(LIB_DIR, "countermeasures", "plugins"), BASE_CUSTOM_PLUGIN_DIR):
if not os.path.exists(directory):
continue
log.info("Loading Countermeasure plugins from %s" % directory)
sys.path.append(directory)
for mod_name in os.listdir(directory):
if mod_name.endswith(".py") and not mod_name.startswith("__"):
try:
mod = p_importlib.import_module(mod_name[:-3])
except:
log.error("Unable to import plugin %s: %s" % (mod_name, traceback.format_exc()))
continue
for name, obj in list(mod.__dict__.items()):
if ((sys.version_info[0] == 3 and type(obj) == type) or (sys.version_info[0] == 2 and type(obj) == types.ClassType)) and name.endswith("Countermeasure"):
try:
plugin = obj()
plugins[plugin.textkey] = plugin
except:
log.error("Unable to instantiate plugin %s: %s" % (mod_name, traceback.format_exc()))
if mod_name.endswith('.json'):
try:
json_counter = open(os.path.join(directory, mod_name))
except Exception:
log.error('Unable to open %s' % os.path.join(directory, mod_name))
log.error(traceback.format_exc())
continue
file_content = json_counter.read()
json_counter.close()
try:
counter_data = json.loads(file_content)
except Exception:
log.error('%s file is not a valid json file to be read' % mod_name)
log.error(traceback.format_exc())
continue
required_fields = ['name', 'textkey', 'command', 'author']
existing_keys = counter_data.keys()
success = True
for key in required_fields:
if key not in existing_keys or not counter_data.get(key):
log.error('%s is missing from the countermeasure declaration of %s' % (key, mod_name))
success = False
break
if not success:
continue
try:
max_runtime = int(counter_data.get('max_runtime'))
except:
log.error('max runtime %s is not valid' % (counter_data.get('max_runtime')))
max_runtime = None
try:
max_frequency = int(counter_data.get('max_frequency'))
except:
log.error('max frequency %s is not valid' % (counter_data.get('max_frequency')))
max_frequency = None
textkey = counter_data.get('textkey')
plugin = JsonPlugin(counter_data.get('command'))
plugin.textkey = counter_data.get('textkey')
plugin.name = counter_data.get('name')
plugin.description = counter_data.get('description')
plugin.wall_announce_delay = counter_data.get('wall_announce_delay')
plugin.author = counter_data.get('author')
plugin.max_runtime = counter_data.get('max_runtime')
plugin.max_frequency = counter_data.get('max_frequency')
plugins[textkey] = plugin
return plugins
def load_agent_config():
agent_config_file = os.path.join(BASE_CONFIG_DIR, PKG_DIR, "%s_agent.cfg" % BRAND)
config_file = configparser.ConfigParser()
config_file.read(agent_config_file)
return config_file
def setup_logging(name):
log_file = os.path.join(BASE_LOG_DIR, PKG_DIR, "countermeasure.log")
root_logger = logging.getLogger(name)
agg_logger = logging.getLogger("Client")
handler = logging.handlers.RotatingFileHandler(log_file, 'a', maxBytes=5 * 1024**2, backupCount=5)
handler.setFormatter(logging.Formatter('%(process)d) '
'%(asctime)s - '
'%(name)s - '
'%(levelname)s - '
'%(message)s'))
root_logger.addHandler(handler)
agg_logger.addHandler(handler)
# If we have a TTY, add a stdout handler
if sys.stdin.isatty():
root_logger.addHandler(logging.StreamHandler())
root_logger.setLevel(logging.INFO)
agg_logger.setLevel(logging.INFO)
return root_logger
def set_last_execution(textkey):
last_execution_directory = os.path.join(BASE_DATA_DIR, PKG_DIR, "countermeasures/last_execution")
if not os.path.exists(last_execution_directory):
os.makedirs(last_execution_directory)
os.system("touch %s" % os.path.join(last_execution_directory, textkey))
def get_last_execution(textkey):
last_execution_directory = os.path.join(BASE_DATA_DIR, PKG_DIR, "countermeasures/last_execution")
if not os.path.exists(last_execution_directory):
os.makedirs(last_execution_directory)
filename = os.path.join(last_execution_directory, textkey)
if not os.path.exists(filename):
return 0
stat = os.stat(filename)
return stat.st_mtime
def install_plugins(url):
log = setup_logging("countermeasure")
log.info("\nFetching remote plugins from %s" % url)
f = tempfile.NamedTemporaryFile(delete=False)
num_installed = 0
ext = url.split(".")[-1]
fname = url.split("/")[-1]
if not os.path.exists(BASE_CUSTOM_PLUGIN_DIR):
os.system('mkdir %s' % BASE_CUSTOM_PLUGIN_DIR)
try:
r = urllib2.urlopen(url)
if "content-disposition" in r.info():
ext = r.info().getheader("content-disposition").split(".")[-1]
f.write(r.read())
f.close()
except:
log.error("Unable to download URL: %s" % traceback.format_exc())
return
if ext in ("tar", "tgz"):
try:
t = tarfile.open(f.name)
for file in t.getnames():
if file.endswith(".py") or file.endswith('.json'):
log.info(" Installing plugin %s" % file)
t.extract(file, BASE_CUSTOM_PLUGIN_DIR)
num_installed += 1
except:
log.error("Unable to extract tar contents: %s" % traceback.format_exc())
elif ext == "zip":
try:
z = zipfile.ZipFile(f.name)
for file in z.namelist():
if file.endswith(".py") or file.endswith(".json"):
log.info(" Installing plugin %s" % file)
z.extract(file, BASE_CUSTOM_PLUGIN_DIR)
num_installed += 1
except:
log.error("Unable to extract zip contents: %s" % traceback.format_exc())
elif ext == "py" or ext == "json":
log.info(" Installing plugin %s" % fname)
os.system("cp %s %s" % (f.name, os.path.join(BASE_CUSTOM_PLUGIN_DIR, fname)))
num_installed += 1
else:
log.error("Unable to install Countermeasure, unknown extension: %s" % ext)
if num_installed:
log.info("\nInstalled %s Countermeasure plugins" % num_installed)
os.system("rm -f %s" % f.name)
def list_plugins():
plugins = load_plugins()
pairs = []
for textkey, plugin in plugins.items():
pairs.append((plugin.name, plugin.author or "", plugin.description))
pairs.sort()
max_name_len = max([len(p[0]) for p in pairs])
max_author_len = max([len(p[1]) for p in pairs])
print("\nAvailable Countermeasures")
print("=========================\n")
fmt = "%-" + str(max_name_len+3) + "s %-" + str(max_author_len+3) +"s %s"
print(fmt % ("Name", "Author", "Description"))
print("-" * 80)
for textkey, author, description in pairs:
print(fmt % (textkey, author, description))
print("")
def validate_plugins():
plugins = load_plugins()
pairs = []
for textkey, plugin in plugins.items():
output = plugin.validate()
if output:
pairs.append((plugin.name, output))
if pairs:
pairs.sort()
max_len = max([len(p[0]) for p in pairs])
print("\nInvalid Countermeasures")
print("=========================")
fmt = "%-" + str(max_len+3) + "s %s"
for textkey, description in pairs:
print(fmt % (textkey, description))
else:
print("\nAll plugins are valid.")
print("")
def validate_sudo():
plugins = load_plugins()
for textkey, plugin in plugins.items():
if plugin.sudo_requirements:
print("\nVerifying sudo requirements for %s" % textkey)
devnull = open("/dev/null", "w")
for app in plugin.sudo_requirements:
# If the app is a list, then we just need at least one of these to be available
if type(app) == list:
valid = any([subprocess.call(("sudo -n -l %s" % subapp).split(), stdout=devnull, stderr=devnull) == 0 for subapp in app])
# If not a list, just check one item
else:
valid = subprocess.call(("sudo -n -l %s" % app).split(), stdout=devnull, stderr=devnull) == 0
print(" %-30s %s" % (("%s:%s" % (textkey, app)), valid and "Pass" or "Missing Permissions"))
print("")
devnull.close()
def execute(hash, textkeys, metadata_file=None):
# Setup logging and get config file
log = setup_logging("countermeasure %s" % hash)
config = load_agent_config()
# Read and parse any metadata if given
cm_metadata = {}
if metadata_file:
try:
f = open(metadata_file, 'r')
metadata_contents = f.read()
f.close()
cm_metadata = json.loads(metadata_contents)
os.remove(metadata_file)
except Exception:
log.error("Problem while proccessing metadata:")
log.error(traceback.format_exc())
cm_metadata = {}
# Get available plugins
plugins = load_plugins()
proxy_info = {}
if config.has_section('agent_proxy'):
proxy_info = config['agent_proxy']
# Iterate through the plugin textkeys, executing each one in succession
for textkey in textkeys:
if textkey not in plugins:
print("Unable to execute unknown plugin %s" % textkey)
log.error("Unable to execute unknown plugin %s" % textkey)
current_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
report_output(plugin_textkey=textkey,
server_key=config.get('agent', 'server_key'),
aggregator_url=config.get(
'agent', 'aggregator_url').lower(),
agent_version=config.get('agent', 'version'),
output=[{'timestamp': current_time, 'format': 'text',
'output': 'Unable to execute plugin %s' % textkey}],
hash=hash,
return_code=1,
status='error',
proxy_info=proxy_info)
continue
plugin = plugins[textkey]
plugin.set_metadata(cm_metadata)
# Check how recently the countermeasure has been run
last_execution = time.time() - get_last_execution(textkey)
if plugin.max_frequency and last_execution < plugin.max_frequency:
log.warning("%s countermeasure skipped, ran too %d seconds ago" % (textkey, last_execution))
continue
print("Executing %s" % textkey)
log.info("Executing %s" % textkey)
# Set last execution time to current time
set_last_execution(textkey)
# Run prepare operation
plugin.prepare()
# Post wall message if needed
if plugin.wall_announce_delay is not None:
os.system("wall 'Executing %s countermeasure'" % textkey)
if plugin.wall_announce_delay:
time.sleep(plugin.wall_announce_delay)
# Execute the plugin
try:
plugin.run()
plugin.status = "success"
except:
plugin.status = "error"
plugin.output = [{"timestamp": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
"format": "text",
"output": "Exception executing plugin: %s" % traceback.format_exc()}]
# Truncate output if it's too long
if len(plugin.output) > MAX_OUTPUT_LENGTH:
plugin.output = plugin.output[:MAX_OUTPUT_LENGTH]
proxy_info = {}
if config.has_section('agent_proxy'):
proxy_info = config['agent_proxy']
# Report the output
report_output(
plugin_textkey=plugin.textkey,
server_key=config.get('agent', 'server_key'),
aggregator_url=config.get('agent', 'aggregator_url').lower(),
agent_version=config.get('agent', 'version'),
output=plugin.output,
return_code=plugin.return_code,
hash=hash,
status=plugin.status,
proxy_info=proxy_info)
log.info("Completed countermeasure %s" % textkey)
def report_output(**kwargs):
aggregator_url = kwargs.get('aggregator_url')
server_key = kwargs.get('server_key')
agent_version = kwargs.get('agent_version')
if not aggregator_url.startswith("http"):
try:
host, port = aggregator_url.split(":")
if port in ("443", "8443"):
aggregator_url = "https://" + aggregator_url
else:
aggregator_url = "http://" + aggregator_url
except:
aggregator_url = "https://" + aggregator_url
aggregator_url = urlparse.urljoin(aggregator_url, "/v2/countermeasure")
payload = {"countermeasure_textkey": kwargs.get('plugin_textkey'),
"server_key": server_key,
"incident_hash": kwargs.get('hash'),
"output": kwargs.get('output'),
"return_code": kwargs.get('return_code'),
"status": kwargs.get('status'),
}
proxy_info = kwargs.get('proxy_info')
aggregator_client = aggregator.Client(aggregator_url, agent_version, server_key, proxy_config=proxy_info)
aggregator_client.call("countermeasure", payload)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["execute", "list_plugins", "install_plugins", "validate_plugins", "validate_sudo"])
parser.add_argument("--url", required=False, help="URL to download plugins from")
parser.add_argument("--hash", required=False, help="Incident hash")
parser.add_argument("--textkeys", required=False, nargs="+", help="CounterMeasure plugin textkeys to execute")
parser.add_argument("--metadata-file", required=False, help="Path to file containing JSON metadata that will be passed to plugin")
args = parser.parse_args()
# Perform logical validation to make sure we got the correct arguments based on the action type
if args.action == "execute" and (args.hash == None or args.textkeys == None):
print("ERROR: Need to provide --hash and --textkey arguments\n")
parser.print_help()
sys.exit(1)
elif args.action == "install_plugins" and args.url == None:
print("ERROR: Need to provide --url argument\n")
parser.print_help()
sys.exit(1)
# Dispatch to action methods
if args.action == "execute":
execute(args.hash, args.textkeys, args.metadata_file)
elif args.action == "list_plugins":
list_plugins()
elif args.action == "install_plugins":
install_plugins(args.url)
elif args.action == "validate_plugins":
validate_plugins()
elif args.action == "validate_sudo":
validate_sudo()