blob: d5e9d8ccf63070ad37e4e8f01ee82cce40b81373 [file]
import asyncio
import copy
import datetime
import glob
import os
import pickle
import tarfile
import shutil
import warnings
import pathlib
from importlib import import_module
from app.objects.c_ability import Ability
from app.objects.c_adversary import Adversary
from app.objects.c_objective import Objective
from app.objects.c_planner import Planner
from app.objects.c_plugin import Plugin
from app.objects.c_source import Source
from app.objects.secondclass.c_executor import Executor, ExecutorSchema
from app.objects.secondclass.c_goal import Goal
from app.objects.secondclass.c_parser import Parser
from app.objects.secondclass.c_requirement import Requirement, RequirementSchema
from app.service.interfaces.i_data_svc import DataServiceInterface
from app.utility.base_service import BaseService
MIN_MODULE_LEN = 1
DATA_BACKUP_DIR = "data/backup"
DATA_FILE_GLOBS = (
'data/abilities/*',
'data/adversaries/*',
'data/facts/*',
'data/objectives/*',
'data/payloads/*',
'data/results/*',
'data/sources/*',
'data/object_store',
)
PAYLOADS_CONFIG_STANDARD_KEY = 'standard_payloads'
PAYLOADS_CONFIG_SPECIAL_KEY = 'special_payloads'
PAYLOADS_CONFIG_EXTENSIONS_KEY = 'extensions'
DEPRECATION_WARNING_LOAD = "Function deprecated and will be removed in a future update. Use load_yaml_file"
class DataService(DataServiceInterface, BaseService):
def __init__(self):
self.log = self.add_service('data_svc', self)
self.schema = dict(agents=[], planners=[], adversaries=[], abilities=[], sources=[], operations=[],
schedules=[], plugins=[], obfuscators=[], objectives=[], data_encoders=[])
self.ram = copy.deepcopy(self.schema)
@staticmethod
def _iter_data_files():
"""Yield paths to data files managed by caldera.
The files paths are relative to the root caldera folder, so they
will begin with "data/".
Note:
This will skip any files starting with '.' (e.g., '.gitkeep').
"""
for data_glob in DATA_FILE_GLOBS:
for f in glob.glob(data_glob):
yield f
@staticmethod
def _delete_file(path):
if not os.path.exists(path):
return
elif os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
@staticmethod
async def destroy():
"""Reset the caldera data directory and server state.
This creates a gzipped tarball backup of the data files tracked by caldera.
Paths are preserved within the tarball, with all files having "data/" as the
root.
"""
if not os.path.exists(DATA_BACKUP_DIR):
os.mkdir(DATA_BACKUP_DIR)
timestamp = datetime.datetime.utcnow().strftime('%Y%m%d%H%M%S')
tarball_path = os.path.join(DATA_BACKUP_DIR, f'backup-{timestamp}.tar.gz')
with tarfile.open(tarball_path, 'w:gz') as tarball:
for file_path in DataService._iter_data_files():
tarball.add(file_path)
DataService._delete_file(file_path)
async def save_state(self):
await self._prune_non_critical_data()
await self.get_service('file_svc').save_file('object_store', pickle.dumps(self.ram), 'data')
async def restore_state(self):
"""
Restore the object database
:return:
"""
if os.path.exists('data/object_store'):
_, store = await self.get_service('file_svc').read_file('object_store', 'data')
# Pickle is only used to load a local file that caldera creates. Pickled data is not
# received over the network.
ram = pickle.loads(store) # nosec
for key in ram.keys():
self.ram[key] = []
for c_object in ram[key]:
await self.store(c_object)
self.log.debug('Restored data from persistent storage')
self.log.debug('There are %s jobs in the scheduler' % len(self.ram['schedules']))
async def apply(self, collection):
if collection not in self.ram:
self.ram[collection] = []
async def load_data(self, plugins=()):
loop = asyncio.get_event_loop()
loop.create_task(self._load(plugins))
async def reload_data(self, plugins=()):
await self._load(plugins)
async def store(self, c_object):
try:
return c_object.store(self.ram)
except Exception as e:
self.log.error('[!] can only store first-class objects: %s' % e)
async def locate(self, object_name, match=None):
try:
return [obj for obj in self.ram[object_name] if obj.match(match)]
except Exception as e:
self.log.error('[!] LOCATE: %s' % e)
async def search(self, value, object_name):
try:
return [obj for obj in self.ram[object_name] if obj.search_tags(value)]
except Exception as e:
self.log.error('[!] SEARCH: %s' % e)
async def remove(self, object_name, match):
try:
self.ram[object_name][:] = [obj for obj in self.ram[object_name] if not obj.match(match)]
except Exception as e:
self.log.error('[!] REMOVE: %s' % e)
async def load_ability_file(self, filename, access):
try:
for entries in self.strip_yml(filename):
for ab in entries:
if type(ab) is not dict:
self.log.error(f'Malformed ability file {filename}. Expected ability entry to be a dictionary, received {type(ab)} instead.')
continue
ability_id = ab.pop('id', None)
if ability_id is not None and type(ability_id) is not str:
ability_id = str(ability_id)
name = ab.pop('name', '')
description = ab.pop('description', '')
tactic = ab.pop('tactic', None)
executors = await self.convert_v0_ability_executor(ab)
technique_id = self.convert_v0_ability_technique_id(ab)
technique_name = self.convert_v0_ability_technique_name(ab)
privilege = ab.pop('privilege', None)
repeatable = ab.pop('repeatable', False)
singleton = ab.pop('singleton', False)
requirements = await self.convert_v0_ability_requirements(ab.pop('requirements', []))
buckets = ab.pop('buckets', [tactic])
ab.pop('access', None)
plugin = self._get_plugin_name(filename)
ab.pop('plugin', plugin)
if tactic and tactic not in filename:
self.log.warn(f'Tactic for ability={ability_id} is not in the ability file path {filename}.')
self.log.warn('Please check that the ability is labeled with the correct tactic and is in the correct location.')
await self._create_ability(ability_id=ability_id, name=name, description=description, tactic=tactic,
technique_id=technique_id, technique_name=technique_name,
executors=executors, requirements=requirements, privilege=privilege,
repeatable=repeatable, buckets=buckets, access=access, singleton=singleton, plugin=plugin,
**ab)
except Exception as e:
self.log.exception(f'Failed to load ability file {filename}: {e}')
async def convert_v0_ability_executor(self, ability_data: dict):
"""Checks if ability file follows v0 executor format, otherwise assumes v1 ability formatting."""
if 'platforms' in ability_data:
return await self.load_executors_from_platform_dict(ability_data.pop('platforms', dict()))
return await self.load_executors_from_list(ability_data.pop('executors', []))
def convert_v0_ability_technique_name(self, ability_data: dict):
"""Checks if ability file follows v0 technique_name format, otherwise assumes v1 ability formatting."""
if 'technique' in ability_data:
return ability_data.pop('technique', dict()).get('name')
return ability_data.pop('technique_name')
def convert_v0_ability_technique_id(self, ability_data: dict):
"""Checks if ability file follows v0 technique_id format, otherwise assumes v1 ability formatting."""
if 'technique' in ability_data:
return ability_data.get('technique', dict()).get('attack_id')
return ability_data.pop('technique_id')
async def convert_v0_ability_requirements(self, requirements_data: list):
"""Checks if ability file follows v0 requirement format, otherwise assumes v1 ability formatting."""
if requirements_data and 'relationship_match' not in requirements_data[0]:
return await self._load_ability_requirements(requirements_data)
return await self.load_requirements_from_list(requirements_data)
async def load_executors_from_platform_dict(self, platforms):
executors = []
for platform_names, platform_executors in platforms.items():
for executor_names, executor in platform_executors.items():
command = executor['command'].strip() if executor.get('command') else None
cleanup = executor['cleanup'].strip() if executor.get('cleanup') else None
code = executor['code'].strip() if executor.get('code') else None
if code:
_, code_path = await self.get_service('file_svc').find_file_path(code)
if code_path:
_, code_data = await self.get_service('file_svc').read_file(code)
code = code_data.decode('utf-8').strip()
language = executor.get('language')
build_target = executor.get('build_target')
payloads = executor.get('payloads')
uploads = executor.get('uploads')
timeout = executor.get('timeout', 60)
variations = executor.get('variations', [])
parsers = await self._load_executor_parsers(executor.get('parsers', []))
for platform_name in platform_names.split(','):
for executor_name in executor_names.split(','):
executors.append(Executor(name=executor_name, platform=platform_name, command=command,
code=code, language=language, build_target=build_target,
payloads=payloads, uploads=uploads, timeout=timeout,
parsers=parsers, cleanup=cleanup, variations=variations))
return executors
async def load_executors_from_list(self, executors: list):
return [ExecutorSchema().load(entry) for entry in executors]
async def load_requirements_from_list(self, requirements: list):
return [RequirementSchema().load(entry) for entry in requirements]
async def load_adversary_file(self, filename, access):
warnings.warn(DEPRECATION_WARNING_LOAD, DeprecationWarning, stacklevel=2)
await self.load_yaml_file(Adversary, filename, access)
async def load_source_file(self, filename, access):
warnings.warn(DEPRECATION_WARNING_LOAD, DeprecationWarning, stacklevel=2)
await self.load_yaml_file(Source, filename, access)
async def load_objective_file(self, filename, access):
warnings.warn(DEPRECATION_WARNING_LOAD, DeprecationWarning, stacklevel=2)
await self.load_yaml_file(Objective, filename, access)
async def load_yaml_file(self, object_class, filename, access):
for src in self.strip_yml(filename):
obj = object_class.load(src)
obj.access = access
obj.plugin = self._get_plugin_name(filename)
await self.store(obj)
async def create_or_update_everything_adversary(self):
abilities = await self.locate('abilities')
if abilities is None:
abilities = []
atomic_ordering = []
for ability in abilities:
try:
if ability.plugin == 'training':
continue
if ability.access == self.Access.RED or ability.access == self.Access.APP:
atomic_ordering.append(ability.ability_id)
except (AttributeError, TypeError):
continue
everything = {
'id': '785baa02-df5d-450a-ab3a-1a863f22b4b0',
'name': 'Everything Bagel',
'description': 'An adversary with all adversary abilities',
'atomic_ordering': atomic_ordering,
}
try:
obj = Adversary.load(everything)
obj.access = self.Access.RED
await self.store(obj)
except Exception as e:
self.log.debug(f"Failed to create everything adversary: {e}")
async def _load(self, plugins=()):
async_tasks = []
if not plugins:
plugins = [p for p in await self.locate('plugins') if p.data_dir and p.enabled]
if not [plugin for plugin in plugins if plugin.data_dir == 'data']:
plugins.append(Plugin(data_dir='data'))
for plug in plugins:
try:
await self._load_payloads(plug)
await self._load_abilities(plug, async_tasks)
await self._load_objectives(plug)
await self._load_adversaries(plug)
await self._load_planners(plug)
await self._load_sources(plug)
await self._load_packers(plug)
except Exception as e:
self.log.debug(repr(e), exc_info=True)
for task in async_tasks:
try:
await task
except Exception as e:
self.log.debug(repr(e), exc_info=True)
await self._load_extensions()
await self._load_data_encoders(plugins)
await self.create_or_update_everything_adversary()
await self._verify_data_sets()
async def _load_adversaries(self, plugin):
for filename in glob.iglob('%s/adversaries/**/*.yml' % plugin.data_dir, recursive=True):
await self.load_yaml_file(Adversary, filename, plugin.access)
async def _load_abilities(self, plugin, tasks=None):
tasks = [] if tasks is None else tasks
for filename in glob.iglob('%s/abilities/**/*.yml' % plugin.data_dir, recursive=True):
tasks.append(asyncio.get_event_loop().create_task(self.load_ability_file(filename, plugin.access)))
@staticmethod
async def _load_ability_requirements(requirements):
loaded_reqs = []
for requirement in requirements:
for module in requirement:
loaded_reqs.append(Requirement.load(dict(module=module, relationship_match=requirement[module])))
return loaded_reqs
@staticmethod
async def _load_executor_parsers(parsers):
ps = []
for module in parsers:
ps.append(Parser.load(dict(module=module, parserconfigs=parsers[module])))
return ps
async def _load_sources(self, plugin):
for filename in glob.iglob('%s/sources/*.yml' % plugin.data_dir, recursive=False):
await self.load_yaml_file(Source, filename, plugin.access)
async def _load_objectives(self, plugin):
for filename in glob.iglob('%s/objectives/*.yml' % plugin.data_dir, recursive=False):
await self.load_yaml_file(Objective, filename, plugin.access)
async def _load_payloads(self, plugin):
payload_config = dict(
standard_payloads=dict(),
special_payloads=dict(),
extensions=dict(),
)
for filename in glob.iglob('%s/payloads/*.yml' % plugin.data_dir, recursive=False):
data = self.strip_yml(filename)
special_payloads = data[0].get(PAYLOADS_CONFIG_SPECIAL_KEY, dict())
extensions = data[0].get(PAYLOADS_CONFIG_EXTENSIONS_KEY, dict())
standard_payloads = data[0].get(PAYLOADS_CONFIG_STANDARD_KEY, dict())
payload_config[PAYLOADS_CONFIG_STANDARD_KEY].update(standard_payloads)
if special_payloads:
await self._apply_special_payload_hooks(special_payloads)
payload_config[PAYLOADS_CONFIG_SPECIAL_KEY].update(special_payloads)
if extensions:
await self._apply_special_extension_hooks(extensions)
payload_config[PAYLOADS_CONFIG_EXTENSIONS_KEY].update(extensions)
self._update_payload_config(payload_config, plugin.name)
def _update_payload_config(self, updates, curr_plugin_name):
payload_config = self.get_config(name='payloads')
curr_standard_payloads = payload_config.get(PAYLOADS_CONFIG_STANDARD_KEY, dict())
curr_special_payloads = payload_config.get(PAYLOADS_CONFIG_SPECIAL_KEY, dict())
curr_extensions = payload_config.get(PAYLOADS_CONFIG_EXTENSIONS_KEY, dict())
new_standard_payloads = updates.get(PAYLOADS_CONFIG_STANDARD_KEY, dict())
new_special_payloads = updates.get(PAYLOADS_CONFIG_SPECIAL_KEY, dict())
new_extensions = updates.get(PAYLOADS_CONFIG_EXTENSIONS_KEY, dict())
self._check_payload_overlaps(curr_standard_payloads, new_standard_payloads, PAYLOADS_CONFIG_STANDARD_KEY,
curr_plugin_name)
self._check_payload_overlaps(curr_special_payloads, new_special_payloads, PAYLOADS_CONFIG_SPECIAL_KEY,
curr_plugin_name)
self._check_payload_overlaps(curr_extensions, new_extensions, PAYLOADS_CONFIG_EXTENSIONS_KEY, curr_plugin_name)
payload_config[PAYLOADS_CONFIG_STANDARD_KEY] = {**curr_standard_payloads, **new_standard_payloads}
payload_config[PAYLOADS_CONFIG_SPECIAL_KEY] = {**curr_special_payloads, **new_special_payloads}
payload_config[PAYLOADS_CONFIG_EXTENSIONS_KEY] = {**curr_extensions, **new_extensions}
self.apply_config(name='payloads', config=payload_config)
def _check_payload_overlaps(self, old_dict, new_dict, config_section_name, curr_plugin_name):
overlap = set(old_dict).intersection(new_dict)
for payload in overlap:
if old_dict[payload] != new_dict[payload]:
self.log.warning('Config for %s already exists in the %s section of the payloads config and will be '
'overridden using new payload config data from plugin %s', payload,
config_section_name, curr_plugin_name)
async def _load_planners(self, plugin):
for filename in glob.iglob('%s/planners/*.yml' % plugin.data_dir, recursive=False):
await self.load_yaml_file(Planner, filename, plugin.access)
async def _load_extensions(self):
payload_config = self._app_configuration.get("payloads", {})
extensions_config = payload_config.get("extensions", {})
for entry in extensions_config:
try:
await self.get_service('file_svc').add_special_payload(entry, extensions_config[entry])
except Exception:
self.log.debug("Failed to load payload extensions")
async def _load_packers(self, plugin):
plug_packers = dict()
for module in glob.iglob('plugins/%s/app/packers/**.py' % plugin.name):
packer = import_module(module.replace('/', '.').replace('\\', '.').replace('.py', ''))
if await packer.check_dependencies(self.get_service('app_svc')):
plug_packers[packer.name] = packer
self.get_service('file_svc').packers.update(plug_packers)
async def _load_data_encoders(self, plugins):
glob_paths = ['app/data_encoders/**.py'] + \
['plugins/%s/app/data_encoders/**.py' % plugin.name for plugin in plugins]
for glob_path in glob_paths:
for module_path in glob.iglob(glob_path):
imported_module = import_module(module_path.replace('/', '.').replace('\\', '.').replace('.py', ''))
try:
encoder = imported_module.load()
await self.store(encoder)
except Exception as e:
self.log.debug(f"Error loading data encoder at {glob_path}: {e}")
async def _create_ability(self, ability_id, name=None, description=None, tactic=None, technique_id=None,
technique_name=None, executors=None, requirements=None, privilege=None,
repeatable=False, buckets=None, access=None, singleton=False, plugin='', **kwargs):
ability = Ability(ability_id=ability_id, name=name, description=description, tactic=tactic,
technique_id=technique_id, technique_name=technique_name, executors=executors,
requirements=requirements, privilege=privilege, repeatable=repeatable, buckets=buckets,
access=access, singleton=singleton, plugin=plugin, **kwargs)
return await self.store(ability)
async def _prune_non_critical_data(self):
self.ram.pop('plugins')
self.ram.pop('obfuscators')
async def _apply_special_extension_hooks(self, special_extensions):
for k, v in special_extensions.items():
if len(v.split('.')) > MIN_MODULE_LEN:
try:
mod = __import__('.'.join(v.split('.')[:-1]), fromlist=[v.split('.')[-1]])
handle = getattr(mod, v.split('.')[-1])
self.get_service('file_svc').special_payloads[k] = handle
except AttributeError:
self.log.error('Unable to properly load {} for payload {} from string.'.format(k, v))
except ModuleNotFoundError:
self.log.warning('Unable to properly load {} for payload {} due to failed import'.format(k, v))
else:
self.log.warning('Unable to decipher target function from string {}.'.format(v))
async def _apply_special_payload_hooks(self, special_payloads):
for k, v in special_payloads.items():
await self.get_service('file_svc').add_special_payload(k, getattr(self.get_service(v['service']),
v['function']))
async def _verify_data_sets(self):
await self._verify_abilities()
await self._verify_default_objective_exists()
await self._verify_adversary_profiles()
async def _verify_abilities(self):
required_fields = ['name', 'description', 'tactic', 'technique_id', 'technique_name']
special_extensions = [special_payload for special_payload in
self.get_service('file_svc').special_payloads if special_payload.startswith('.')]
for ability in await self.locate('abilities'):
for field in required_fields:
if not getattr(ability, field):
setattr(ability, field, 'auto-generated')
self.log.warning('Missing required field in ability %s: %s' % (ability.ability_id, field))
for executor in ability.executors:
for payload in executor.payloads:
payload_name = payload
if self.is_uuid4(payload):
payload_name, _ = self.get_service('file_svc').get_payload_name_from_uuid(payload)
if (executor.code and payload_name == executor.build_target) or \
any(payload_name.endswith(extension) for extension in special_extensions):
continue
_, path = await self.get_service('file_svc').find_file_path(payload_name)
if not path:
self.log.warning('Payload referenced in %s but not found: %s', ability.ability_id, payload)
continue
async def _verify_default_objective_exists(self):
if not await self.locate('objectives', match=dict(name='default')):
await self.store(Objective(id='495a9828-cab1-44dd-a0ca-66e58177d8cc', name='default',
description='This is a default objective that runs forever.', goals=[Goal()]))
async def _verify_adversary_profiles(self):
for adv in await self.locate('adversaries'):
adv.verify(log=self.log, abilities=self.ram['abilities'], objectives=self.ram['objectives'])
def _get_plugin_name(self, filename):
path_components = pathlib.PurePath(filename).parts
num_parts = len(path_components)
for i, part in enumerate(path_components):
if part == 'plugins' and i < num_parts - 1:
return path_components[i + 1]
return ''
async def get_facts_from_source(self, fact_source_id):
fact_sources = await self.locate('sources', match=dict(id=fact_source_id))
if len(fact_sources) == 0:
return []
elif len(fact_sources) > 1:
self.log.error('Found multiple fact sources with the same id', fact_source_id)
return []
return fact_sources[0].facts