blob: 3925c7f6e5c35fa81ef7fb429b488ff7946bdcfa [file]
import copy
import os
import pickle
import shutil
import tarfile
import uuid
from datetime import datetime
import app.service.data_svc
from app.utility.base_service import BaseService
from app.objects.secondclass.c_fact import Fact, WILDCARD_STRING
from app.objects.secondclass.c_relationship import Relationship
DATA_BACKUP_DIR = app.service.data_svc.DATA_BACKUP_DIR
FACT_STORE_PATH = f"data{os.path.sep}fact_store"
class BaseKnowledgeService(BaseService):
def __init__(self):
self.log = self.create_logger('knowledge_svc')
self.schema = dict(facts=[], relationships=[], rules=[], constraints=dict())
self.fact_ram = copy.deepcopy(self.schema)
# -- Fact API --
async def _add_fact(self, fact, constraints=None):
"""
Add a fact to the internal store
:param fact: Fact to add
:param constraints: any potential constraints
"""
# facts can now be highly controlled, with visibility at the operation level, agent(s) level, or
# custom groupings
if not any(x == fact for x in self.fact_ram['facts']):
fact._knowledge_id = uuid.uuid4()
self.fact_ram['facts'].append(fact)
if constraints:
self.fact_ram['constraints'][fact._knowledge_id] = constraints
async def _update_fact(self, criteria, updates):
"""
Update a fact in the internal store
:param criteria: dictionary containing fields to match on
:param updates: dictionary containing fields to replace
"""
matches = await self._get_facts(criteria)
for match in matches:
for k, v in updates.items():
if getattr(match, k, False):
setattr(match, k, v)
async def _get_facts(self, criteria, restrictions=None):
"""
Retrieve a fact from the internal store
:param criteria: dictionary containing fields to match on
:return: list of facts matching the criteria
"""
complete_list = await self._locate('facts', query=criteria)
return await self._apply_restrictions(complete_list, restrictions)
async def _delete_fact(self, criteria):
"""
Delete a fact from the internal store
:param criteria: dictionary containing fields to match on
"""
return await self._remove('facts', criteria)
async def _get_meta_facts(self, meta_fact=None, agent=None, group=None):
# Returns the complete set of facts associated with a meta-fact construct
raise NotImplementedError
async def _get_fact_origin(self, fact):
"""
Identify the place where a fact originated, either the source that loaded it or its original link
:param fact: Fact to get origin for (can be either a trait string or a full blown fact)
:return: tuple - (String of either origin source id or origin link id, fact origin type)
"""
workspace = copy.deepcopy(fact)
if not getattr(workspace, 'links', False):
fact_search = await self._get_facts(dict(trait=workspace))
if fact_search:
workspace = fact_search[0]
else:
return None, None
if workspace.links:
return str(workspace.links[0]), workspace.origin_type # Return the id of the first link associated
elif workspace.source:
return str(workspace.source), workspace.origin_type # Return the source of the fact if not found
return None, None # Default return value
# -- Relationships API --
async def _get_relationships(self, criteria, restrictions=None):
"""
Retrieve relationships from the internal store
:param criteria: dictionary containing fields to match on
:return: list of matching relationships
"""
complete_list = await self._locate('relationships', query=criteria)
return await self._apply_restrictions(complete_list, restrictions)
async def _add_relationship(self, relationship, constraints=None):
"""
Add a relationship to the internal store
:param relationship: Relationship object to add
:param constraints: optional constraints on the use of the relationship
"""
if not any((x.source == relationship.source) and (x.edge == relationship.edge) and
(x.target == relationship.target) and (x.origin == relationship.origin or not relationship.origin)
for x in self.fact_ram['relationships']):
relationship._knowledge_id = uuid.uuid4()
self.fact_ram['relationships'].append(relationship)
if constraints:
self.fact_ram['constraints'][relationship._knowledge_id] = constraints
async def _update_relationship(self, criteria, updates):
"""
Update a relationship in the internal store
:param criteria: dictionary containing fields to match on
:param updates: dictionary containing fields to modify
"""
matches = await self._get_relationships(criteria)
for match in matches:
for k, v in updates.items():
if getattr(match, k, "eMpTy") != "eMpTy":
if isinstance(getattr(match, k), Fact) and not isinstance(v, Fact):
handle = getattr(match, k)
for x in v.keys():
setattr(handle, x, v[x])
else:
setattr(match, k, v)
async def _delete_relationship(self, criteria):
"""
Remove a relationship from the internal store
:param criteria: dictionary containing fields to match on
"""
return await self._remove('relationships', criteria)
# --- Rule API ---
async def _add_rule(self, rule, constraints=None):
"""
Add a rule to the internal store
:param rule: Rule object to add
:param constraints: dictionary containing fields to match on
"""
###
# rule.action: [DENY, ALLOW, EXCLUSIVE, EXCLUSIVE_TRAIT, EXCLUSIVE_VALUE], 'EXCLUSIVE_*' actions denote that
# the trait/value will be made mutually exclusive in its use to the agent/group/operation that is
# specified for. Essentially a fact is binded to mutex, and only one action can be using the fact
# at any one time.
###
if not any((x.action == rule.action) and (x.trait == rule.trait) for x in self.fact_ram['rules']):
rule._knowledge_id = uuid.uuid4()
self.fact_ram['rules'].append(rule)
if constraints:
self.fact_ram['constraints'][rule._knowledge_id] = constraints
async def _get_rules(self, criteria, restrictions=None):
"""
Retrieve rules from the internal store
:param criteria: dictionary containing fields to match on
:return: list of matching rules
"""
complete_list = [x for x in self.fact_ram['rules'] if await self._check_rule(x, criteria, True)]
return await self._apply_restrictions(complete_list, restrictions)
async def _delete_rule(self, criteria):
"""
Remove a rule from the internal store
:param criteria: dictionary containing fields to match on
"""
sublist = [x for x in self.fact_ram['rules'] if await self._check_rule(x, criteria)]
await self._clear_matching_constraints(sublist)
self.fact_ram['rules'][:] = [x for x in self.fact_ram['rules'] if x not in sublist]
return
@staticmethod
async def _check_rule(rule, desired_quals, wildcard=False):
"""
Check whether or not a rule matches the provided criteria
:param rule: Rule object to check
:param desired_quals: dictionary containing fields to match on
:param wildcard: whether or not to do wildcard matching on the 'match' field
:return:
"""
criteria_matches = []
for k, v in desired_quals.items():
if getattr(rule, k) == v:
criteria_matches.append(True)
elif k == 'match' and wildcard: # support wildcard matching
val = getattr(rule, k)
local_stub = val.split('*')
input_stub = v.split('*')
if all(x in v for x in local_stub) or all(x in val for x in input_stub):
criteria_matches.append(True)
if len(criteria_matches) == len(desired_quals):
return True
async def _locate(self, object_name, query=None):
"""
Locate a matching object in the internal store
:param object_name: object type
:param query: dictionary of fields to match on
:return: list of matching objects
"""
try:
return [obj for obj in self.fact_ram[object_name] if self._wildcard_match(obj, query)]
except Exception as e:
self.log.error('[!] LOCATE: %s' % e)
async def _remove(self, object_name, query):
"""
Remove objects from the internal store
:param object_name: object type
:param query: dictionary of fields to match on
"""
try:
sublist = [obj for obj in self.fact_ram[object_name] if self._wildcard_match(obj, query)]
await self._clear_matching_constraints(sublist)
self.fact_ram[object_name][:] = [obj for obj in self.fact_ram[object_name] if obj not in sublist]
except Exception as e:
self.log.error('[!] REMOVE: %s' % e)
async def _clear_matching_constraints(self, objs):
"""
Remove constraints associated with objects as part of deletion
:param objs: list of objects being removed
"""
for obj in objs:
if obj._knowledge_id in self.fact_ram['constraints']:
del self.fact_ram['constraints'][obj._knowledge_id]
async def _get_matching_constraints(self, objs):
"""
Identify matching constraints associated with objects
:param objs: list of objects to get constraints for
:return: list of relevant constraints
"""
k_ids = [obj._knowledge_id for obj in objs]
return [self.fact_ram['constraints'][constraint] for constraint in self.fact_ram['constraints']
if constraint in k_ids]
@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
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.
:return: None
"""
if not os.path.exists(DATA_BACKUP_DIR):
os.mkdir(DATA_BACKUP_DIR)
timestamp = 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:
if os.path.isfile(FACT_STORE_PATH):
tarball.add(FACT_STORE_PATH)
BaseKnowledgeService._delete_file(FACT_STORE_PATH)
async def _save_state(self):
"""
Saves the current internal store state to disk
:return: None
"""
await self.get_service('file_svc').save_file(FACT_STORE_PATH.split(os.path.sep)[1], pickle.dumps(self.fact_ram),
FACT_STORE_PATH.split(os.path.sep)[0])
async def _restore_state(self):
"""
Loads the internal store state from disk
:return: None
"""
if os.path.exists(FACT_STORE_PATH):
_, store = await self.get_service('file_svc').read_file(FACT_STORE_PATH.split(os.path.sep)[1],
FACT_STORE_PATH.split(os.path.sep)[0])
# 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.fact_ram[key] = []
for c_object in ram[key]:
handle = self._load_wrapper(key)
constraints = []
if c_object._knowledge_id in ram[key]:
constraints = ram[key][c_object._knowledge_id]
await handle(c_object, constraints=constraints)
self.log.debug('Restored data from persistent storage')
def _load_wrapper(self, key):
"""
Support wrapper to process different object types during load
:param key: type of object to load
:return: function handle to the correct loader function for the associated object type
"""
if key == 'facts':
return self._add_fact
elif key == 'relationships':
return self._add_relationship
elif key == 'rules':
return self._add_rule
async def _apply_restrictions(self, complete_list, restrictions):
"""
Apply restrictions to a list of objects (type agnostic)
:param complete_list: List of objects to apply restrictions to
:param restrictions: The restrictions to be applied (checked against the constraints internal store)
:return: filtered list of input objects
"""
def _check_restrictions(existing_limitations, desired_limitations):
for restriction_name, restriction_value in desired_limitations:
if restriction_name in existing_limitations:
if existing_limitations[restriction_name] != restriction_value:
return False
return True
if not restrictions:
return complete_list
ret = []
for entry in complete_list:
constraints = await self._get_matching_constraints([entry])
if _check_restrictions(constraints, restrictions):
ret.append(entry)
return ret
def _wildcard_match(self, obj, criteria):
"""
Modified variant of the normal `match` method for objects that will return matches for wildcard fields
[fact object].source
[relationship object].origin
:param obj: The object to validate
:param criteria: The values to check against
"""
if not criteria:
return obj
criteria_matches = []
for k, v in criteria.items():
if type(v) is tuple:
for val in v:
if getattr(obj, k) == val:
criteria_matches.append(True)
else:
if getattr(obj, k) == v:
criteria_matches.append(True)
elif isinstance(getattr(obj, k), Fact): # this is a match based on a fact object in a relationship
if isinstance(v, Fact):
if getattr(obj, k) == v:
criteria_matches.append(True)
elif self._wildcard_match(getattr(obj, k), v):
criteria_matches.append(True)
else: # Wildcard match check
if (k == 'source' and isinstance(obj, Fact)) or (k == 'origin' and isinstance(obj, Relationship)):
if getattr(obj, k) == WILDCARD_STRING:
criteria_matches.append(True)
if len(criteria_matches) >= len(criteria) and all(criteria_matches):
return obj