528 lines
No EOL
21 KiB
Python
528 lines
No EOL
21 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import functools
|
|
import time
|
|
from pathlib import Path
|
|
import json
|
|
import re
|
|
from typing import Union
|
|
import jq
|
|
|
|
import db
|
|
from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search
|
|
import misp_api
|
|
from appConfig import logger
|
|
|
|
|
|
ACTIVE_EXERCISES_DIR = "active_exercises"
|
|
LAST_BACKUP = {}
|
|
|
|
def debounce_check_active_tasks(debounce_seconds: int = 1):
|
|
func_last_execution_time = {}
|
|
def decorator(func):
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
user_id = args[0]
|
|
now = time.time()
|
|
key = f"{user_id}_{func.__name__}"
|
|
if key not in func_last_execution_time:
|
|
func_last_execution_time[key] = now
|
|
return func(*args, **kwargs)
|
|
elif now >= func_last_execution_time[key] + debounce_seconds:
|
|
func_last_execution_time[key] = now
|
|
return func(*args, **kwargs)
|
|
else:
|
|
logger.debug(f">> Debounced for `{user_id}`")
|
|
return None
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def load_exercises() -> bool:
|
|
db.ALL_EXERCISES = read_exercise_dir()
|
|
if not is_validate_exercises(db.ALL_EXERCISES):
|
|
logger.error('Issue while validating exercises')
|
|
return False
|
|
init_inject_flow()
|
|
init_exercises_tasks()
|
|
return True
|
|
|
|
|
|
def read_exercise_dir():
|
|
script_dir = Path(__file__).parent
|
|
target_dir = script_dir / ACTIVE_EXERCISES_DIR
|
|
json_files = target_dir.glob("*.json")
|
|
exercises = []
|
|
for json_file in json_files:
|
|
with open(json_file) as f:
|
|
parsed_exercise = json.load(f)
|
|
exercises.append(parsed_exercise)
|
|
return exercises
|
|
|
|
|
|
def backup_exercises_progress():
|
|
global LAST_BACKUP
|
|
toBackup = {
|
|
'EXERCISES_STATUS': db.EXERCISES_STATUS,
|
|
'SELECTED_EXERCISES': db.SELECTED_EXERCISES,
|
|
'USER_ID_TO_EMAIL_MAPPING': db.USER_ID_TO_EMAIL_MAPPING,
|
|
'USER_ID_TO_AUTHKEY_MAPPING': db.USER_ID_TO_AUTHKEY_MAPPING,
|
|
}
|
|
if toBackup != LAST_BACKUP:
|
|
with open('backup.json', 'w') as f:
|
|
json.dump(toBackup, f)
|
|
LAST_BACKUP = toBackup
|
|
|
|
|
|
def restore_exercices_progress():
|
|
try:
|
|
|
|
with open('backup.json', 'r') as f:
|
|
data = json.load(f)
|
|
db.EXERCISES_STATUS = data['EXERCISES_STATUS']
|
|
db.SELECTED_EXERCISES = data['SELECTED_EXERCISES']
|
|
db.USER_ID_TO_EMAIL_MAPPING = {}
|
|
for user_id_str, email in data['USER_ID_TO_EMAIL_MAPPING'].items():
|
|
db.USER_ID_TO_EMAIL_MAPPING[int(user_id_str)] = email
|
|
db.USER_ID_TO_AUTHKEY_MAPPING = {}
|
|
for user_id_str, authkey in data['USER_ID_TO_AUTHKEY_MAPPING'].items():
|
|
db.USER_ID_TO_AUTHKEY_MAPPING[int(user_id_str)] = authkey
|
|
except:
|
|
logger.info('Could not restore exercise progress')
|
|
resetAll()
|
|
|
|
if len(db.EXERCISES_STATUS) == 0:
|
|
init_exercises_tasks()
|
|
|
|
|
|
def resetAll():
|
|
db.EXERCISES_STATUS = {}
|
|
db.SELECTED_EXERCISES = []
|
|
db.USER_ID_TO_EMAIL_MAPPING = {}
|
|
db.USER_ID_TO_AUTHKEY_MAPPING = {}
|
|
init_exercises_tasks()
|
|
|
|
|
|
def is_validate_exercises(exercises: list) -> bool:
|
|
exercises_uuid = set()
|
|
tasks_uuid = set()
|
|
exercise_by_uuid = {}
|
|
task_by_uuid = {}
|
|
for exercise in exercises:
|
|
e_uuid = exercise['exercise']['uuid']
|
|
if e_uuid in exercises_uuid:
|
|
logger.error(f"Duplicated UUID {e_uuid}. ({exercise['exercise']['name']}, {exercise_by_uuid[e_uuid]['exercise']['name']})")
|
|
return False
|
|
exercises_uuid.add(e_uuid)
|
|
exercise_by_uuid[e_uuid] = exercise
|
|
for inject in exercise['injects']:
|
|
t_uuid = inject['uuid']
|
|
if t_uuid in tasks_uuid:
|
|
logger.error(f"Duplicated UUID {t_uuid}. ({inject['name']}, {task_by_uuid[t_uuid]['name']})")
|
|
return False
|
|
tasks_uuid.add(t_uuid)
|
|
task_by_uuid[t_uuid] = inject
|
|
|
|
for inject_evaluation in inject.get('inject_evaluation', []):
|
|
if inject_evaluation.get('evaluation_strategy', None) == 'data_filtering':
|
|
for evaluation in inject_evaluation.get('parameters', []):
|
|
jq_path = list(evaluation.keys())[0]
|
|
try:
|
|
jq.compile(jq_path)
|
|
except ValueError as e:
|
|
logger.error(f"[{t_uuid} :: {inject['name']}] Could not compile jq path `{jq_path}`\n", e)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def init_inject_flow():
|
|
for exercise in db.ALL_EXERCISES:
|
|
for inject in exercise['injects']:
|
|
inject['exercise_uuid'] = exercise['exercise']['uuid']
|
|
db.INJECT_BY_UUID[inject['uuid']] = inject
|
|
|
|
for exercise in db.ALL_EXERCISES:
|
|
for inject_flow in exercise['inject_flow']:
|
|
db.INJECT_REQUIREMENTS_BY_INJECT_UUID[inject_flow['inject_uuid']] = inject_flow['requirements']
|
|
db.INJECT_SEQUENCE_BY_INJECT_UUID[inject_flow['inject_uuid']] = []
|
|
for sequence in inject_flow['sequence'].get('followed_by', []):
|
|
db.INJECT_SEQUENCE_BY_INJECT_UUID[inject_flow['inject_uuid']].append(sequence)
|
|
|
|
|
|
def init_exercises_tasks():
|
|
for exercise in db.ALL_EXERCISES:
|
|
max_score = 0
|
|
tasks = {}
|
|
for inject in exercise['injects']:
|
|
score = 0
|
|
try:
|
|
for inject_eval in inject['inject_evaluation']:
|
|
score += inject_eval['score_range'][1]
|
|
except KeyError:
|
|
pass
|
|
max_score += score
|
|
tasks[inject['uuid']] = {
|
|
"name": inject['name'],
|
|
"uuid": inject['uuid'],
|
|
"completed_by_user": [],
|
|
"score": score,
|
|
}
|
|
db.EXERCISES_STATUS[exercise['exercise']['uuid']] = {
|
|
'uuid': exercise['exercise']['uuid'],
|
|
'name': exercise['exercise']['name'],
|
|
'tasks': tasks,
|
|
'max_score': max_score,
|
|
}
|
|
|
|
|
|
def get_exercises():
|
|
exercises = []
|
|
for exercise in db.ALL_EXERCISES:
|
|
tasks = []
|
|
for inject in exercise['injects']:
|
|
score = db.EXERCISES_STATUS[exercise['exercise']['uuid']]['tasks'][inject['uuid']]['score']
|
|
requirements = db.INJECT_REQUIREMENTS_BY_INJECT_UUID[inject['uuid']]
|
|
tasks.append(
|
|
{
|
|
"name": inject['name'],
|
|
"uuid": inject['uuid'],
|
|
"description": inject.get('description', ''),
|
|
"score": score,
|
|
"requirements": requirements,
|
|
}
|
|
)
|
|
exercises.append(
|
|
{
|
|
"name": exercise['exercise']['name'],
|
|
"uuid": exercise['exercise']['uuid'],
|
|
"description": exercise['exercise']['description'],
|
|
"level": exercise['exercise']['meta']['level'],
|
|
"priority": exercise['exercise']['meta'].get('priority', 50),
|
|
"tasks": tasks,
|
|
}
|
|
)
|
|
exercises = sorted(exercises, key=lambda d: d['priority'])
|
|
return exercises
|
|
|
|
|
|
def get_selected_exercises():
|
|
return db.SELECTED_EXERCISES
|
|
|
|
|
|
def change_exercise_selection(exercise_uuid: str, selected: bool):
|
|
if selected:
|
|
if exercise_uuid not in db.SELECTED_EXERCISES:
|
|
db.SELECTED_EXERCISES.append(exercise_uuid)
|
|
else:
|
|
if exercise_uuid in db.SELECTED_EXERCISES:
|
|
db.SELECTED_EXERCISES.remove(exercise_uuid)
|
|
|
|
|
|
def resetAllExerciseProgress():
|
|
for user_id in db.USER_ID_TO_EMAIL_MAPPING.keys():
|
|
for exercise_status in db.EXERCISES_STATUS.values():
|
|
for task in exercise_status['tasks'].values():
|
|
mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid'])
|
|
backup_exercises_progress()
|
|
|
|
|
|
def resetAllCommand():
|
|
resetAll()
|
|
backup_exercises_progress()
|
|
|
|
|
|
def get_completed_tasks_for_user(user_id: int):
|
|
completion = get_completion_for_users().get(user_id, {})
|
|
completed_tasks = {}
|
|
for exec_uuid, tasks in completion.items():
|
|
completed_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if completed]
|
|
return completed_tasks
|
|
|
|
def get_incomplete_tasks_for_user(user_id: int):
|
|
completion = get_completion_for_users().get(user_id, {})
|
|
incomplete_tasks = {}
|
|
for exec_uuid, tasks in completion.items():
|
|
incomplete_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if not completed]
|
|
return incomplete_tasks
|
|
|
|
|
|
def get_available_tasks_for_user(user_id: int) -> list[str]:
|
|
available_tasks = []
|
|
completed = get_completed_tasks_for_user(user_id)
|
|
incomplete = get_incomplete_tasks_for_user(user_id)
|
|
for exec_uuid, tasks in incomplete.items():
|
|
for task_uuid in tasks:
|
|
requirements = db.INJECT_REQUIREMENTS_BY_INJECT_UUID[task_uuid]
|
|
requirement_met = 'inject_uuid' not in requirements or requirements['inject_uuid'] in completed[exec_uuid]
|
|
if requirement_met:
|
|
available_tasks.append(task_uuid)
|
|
return available_tasks
|
|
|
|
|
|
def get_model_action(data: dict):
|
|
if 'Log' in data or 'AuditLog' in data:
|
|
data = data['Log'] if 'Log' in data else data['AuditLog']
|
|
if 'model' in data and 'action' in data:
|
|
return (data['model'], data['action'],)
|
|
return (None, None,)
|
|
|
|
def is_accepted_query(data: dict) -> bool:
|
|
model, action = get_model_action(data)
|
|
if model in ['Event', 'Attribute', 'Object', 'Tag',]:
|
|
if action in ['add', 'edit', 'delete', 'publish', 'tag']:
|
|
if 'Log' in data:
|
|
if data['Log']['change'].startswith('Validation errors:'):
|
|
return False
|
|
return True
|
|
|
|
if data.get('user_agent', None) == 'misp-exercise-dashboard':
|
|
return None
|
|
url = data.get('url', None)
|
|
if url is not None:
|
|
return url in [
|
|
'/attributes/restSearch',
|
|
'/events/restSearch',
|
|
]
|
|
return False
|
|
|
|
|
|
def get_completion_for_users():
|
|
completion_per_user = {int(user_id): {} for user_id in db.USER_ID_TO_EMAIL_MAPPING.keys()}
|
|
for exercise_status in db.EXERCISES_STATUS.values():
|
|
for user_id in completion_per_user.keys():
|
|
completion_per_user[int(user_id)][exercise_status['uuid']] = {}
|
|
|
|
for task in exercise_status['tasks'].values():
|
|
for user_id in completion_per_user.keys():
|
|
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
|
|
for entry in task['completed_by_user']:
|
|
user_id = int(entry['user_id'])
|
|
if user_id in completion_per_user: # Ensure the user_id is known in USER_ID_TO_EMAIL_MAPPING
|
|
completion_per_user[user_id][exercise_status['uuid']][task['uuid']] = entry
|
|
|
|
return completion_per_user
|
|
|
|
|
|
def get_score_for_task_completion(tasks_completion: dict) -> int:
|
|
score = 0
|
|
for inject_uuid, completed in tasks_completion.items():
|
|
if not completed:
|
|
continue
|
|
inject = db.INJECT_BY_UUID[inject_uuid]
|
|
try:
|
|
for inject_eval in inject['inject_evaluation']:
|
|
score += inject_eval['score_range'][1]
|
|
except KeyError:
|
|
pass
|
|
return score
|
|
|
|
|
|
def mark_task_completed(user_id: int, exercise_uuid: str , task_uuid: str):
|
|
is_completed = any(filter(lambda x: x['user_id'] == user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
|
|
if not is_completed:
|
|
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'].append({
|
|
'user_id': user_id,
|
|
'timestamp': time.time(),
|
|
'first_completion': False,
|
|
})
|
|
# Update who was the first to complete the task
|
|
first_completion_index = None
|
|
first_completion_time = time.time()
|
|
for i, entry in enumerate(db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']):
|
|
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][i]['first_completion'] = False
|
|
if entry['timestamp'] < first_completion_time:
|
|
first_completion_time = entry['timestamp']
|
|
first_completion_index = i
|
|
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][first_completion_index]['first_completion'] = True
|
|
|
|
|
|
def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
|
|
completed_without_user = list(filter(lambda x: x['user_id'] != user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
|
|
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'] = completed_without_user
|
|
|
|
|
|
def get_progress():
|
|
completion_for_users = get_completion_for_users()
|
|
progress = {}
|
|
for user_id in completion_for_users.keys():
|
|
if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
|
|
print('unknown user id', user_id)
|
|
continue
|
|
progress[user_id] = {
|
|
'email': db.USER_ID_TO_EMAIL_MAPPING[user_id],
|
|
'user_id': user_id,
|
|
'exercises': {},
|
|
}
|
|
for exec_uuid, tasks_completion in completion_for_users[user_id].items():
|
|
progress[user_id]['exercises'][exec_uuid] = {
|
|
'tasks_completion': tasks_completion,
|
|
'score': get_score_for_task_completion(tasks_completion),
|
|
'max_score': db.EXERCISES_STATUS[exec_uuid]['max_score'],
|
|
}
|
|
return progress
|
|
|
|
|
|
async def check_inject(user_id: int, inject: dict, data: dict, context: dict) -> bool:
|
|
for inject_evaluation in inject['inject_evaluation']:
|
|
success = await inject_checker_router(user_id, inject_evaluation, data, context)
|
|
if not success:
|
|
logger.info(f"Task not completed[{user_id}]: {inject['uuid']}")
|
|
return False
|
|
mark_task_completed(user_id, inject['exercise_uuid'], inject['uuid'])
|
|
logger.info(f"Task success[{user_id}]: {inject['uuid']}")
|
|
return True
|
|
|
|
|
|
def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
|
|
if 'evaluation_context' not in inject_evaluation or len(inject_evaluation['evaluation_context']) == 0:
|
|
return True
|
|
if 'request_is_rest' in inject_evaluation['evaluation_context']:
|
|
if 'request_is_rest' in context:
|
|
if inject_evaluation['evaluation_context']['request_is_rest'] == context['request_is_rest']:
|
|
return True
|
|
else:
|
|
logger.debug('Request type does not match state of `request_is_rest`')
|
|
return False
|
|
else:
|
|
logger.debug('Unknown request type')
|
|
return False
|
|
return True
|
|
|
|
async def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
|
|
if not is_valid_evaluation_context(user_id, inject_evaluation, data, context):
|
|
return False
|
|
|
|
if 'evaluation_strategy' not in inject_evaluation:
|
|
logger.warning('Evaluation strategy not specified in inject')
|
|
return False
|
|
|
|
data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data)
|
|
if data_to_validate is None:
|
|
logger.debug('Could not fetch data to validate')
|
|
return False
|
|
|
|
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
|
|
return eval_data_filtering(user_id, inject_evaluation, data_to_validate, context)
|
|
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
|
|
expected_data = data_to_validate['expected_data']
|
|
data_to_validate = data_to_validate['data_to_validate']
|
|
return eval_query_mirror(user_id, expected_data, data_to_validate, context)
|
|
elif inject_evaluation['evaluation_strategy'] == 'query_search':
|
|
return eval_query_search(user_id, inject_evaluation, data_to_validate, context)
|
|
return False
|
|
|
|
|
|
async def get_data_to_validate(user_id: int, inject_evaluation: dict, data: dict) -> Union[dict, list, str, None]:
|
|
data_to_validate = None
|
|
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
|
|
event_id = parse_event_id_from_log(data)
|
|
data_to_validate = await fetch_data_for_data_filtering(event_id=event_id)
|
|
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
|
|
perfomed_query = parse_performed_query_from_log(data)
|
|
data_to_validate = await fetch_data_for_query_mirror(user_id, inject_evaluation, perfomed_query)
|
|
elif inject_evaluation['evaluation_strategy'] == 'query_search':
|
|
data_to_validate = await fetch_data_for_query_search(user_id, inject_evaluation)
|
|
return data_to_validate
|
|
|
|
|
|
def parse_event_id_from_log(data: dict) -> Union[int, None]:
|
|
event_id_from_change_field_regex = r".*event_id \(.*\) => \((\d+)\).*"
|
|
event_id_from_title_field_regex = r".*from Event \((\d+)\).*"
|
|
if 'Log' in data:
|
|
log = data['Log']
|
|
if 'model' in log and 'model_id' in log and log['model'] == 'Event':
|
|
return int(log['model_id'])
|
|
if 'change' in log:
|
|
event_id_search = re.search(event_id_from_change_field_regex, log['change'], re.IGNORECASE)
|
|
if event_id_search is not None:
|
|
event_id = event_id_search.group(1)
|
|
return event_id
|
|
if 'title' in log:
|
|
event_id_search = re.search(event_id_from_title_field_regex, log['title'], re.IGNORECASE)
|
|
if event_id_search is not None:
|
|
event_id = event_id_search.group(1)
|
|
return event_id
|
|
elif 'AuditLog' in data:
|
|
log = data['AuditLog']
|
|
if 'model' in log and 'model_id' in log and log['model'] == 'Event':
|
|
return int(log['model_id'])
|
|
if 'change' in log:
|
|
if 'event_id' in log and log['event_id'] is not None:
|
|
return int(log['event_id'])
|
|
return None
|
|
|
|
|
|
def parse_performed_query_from_log(data: dict) -> Union[dict, None]:
|
|
performed_query = {
|
|
'request_method': '',
|
|
'url': '',
|
|
'payload': {},
|
|
}
|
|
if 'request' in data:
|
|
request = data['request']
|
|
performed_query['request_method'] = data.get('request_method', None)
|
|
performed_query['url'] = data.get('url', None)
|
|
if request.startswith('application/json\n\n'):
|
|
query_raw = request.replace('application/json\n\n', '')
|
|
try:
|
|
payload = json.loads(query_raw)
|
|
performed_query['payload'] = payload
|
|
except:
|
|
pass
|
|
if performed_query['request_method'] is not None and performed_query['url'] is not None:
|
|
return performed_query
|
|
return None
|
|
|
|
|
|
async def fetch_data_for_data_filtering(event_id=None) -> Union[None, dict]:
|
|
data = None
|
|
if event_id is not None:
|
|
data = await misp_api.getEvent(event_id)
|
|
return data
|
|
|
|
|
|
async def fetch_data_for_query_mirror(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]:
|
|
data = None
|
|
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
|
|
if perfomed_query is not None:
|
|
if 'evaluation_context' not in inject_evaluation and 'query_context' not in inject_evaluation['evaluation_context']:
|
|
return None
|
|
query_context = inject_evaluation['evaluation_context']['query_context']
|
|
expected_method = query_context['request_method']
|
|
expected_url = query_context['url']
|
|
expected_payload = inject_evaluation['parameters'][0]
|
|
expected_data = await misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload)
|
|
data_to_validate = await misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload'])
|
|
data = {
|
|
'expected_data' : expected_data,
|
|
'data_to_validate' : data_to_validate,
|
|
}
|
|
return data
|
|
|
|
|
|
async def fetch_data_for_query_search(user_id: int, inject_evaluation: dict) -> Union[None, dict]:
|
|
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
|
|
if 'evaluation_context' not in inject_evaluation and 'query_context' not in inject_evaluation['evaluation_context']:
|
|
return None
|
|
query_context = inject_evaluation['evaluation_context']['query_context']
|
|
search_method = query_context['request_method']
|
|
search_url = query_context['url']
|
|
search_payload = query_context['payload']
|
|
search_data = await misp_api.doRestQuery(authkey, search_method, search_url, search_payload)
|
|
return search_data
|
|
|
|
|
|
@debounce_check_active_tasks(debounce_seconds=2)
|
|
async def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
|
|
succeeded_once = False
|
|
available_tasks = get_available_tasks_for_user(user_id)
|
|
for task_uuid in available_tasks:
|
|
inject = db.INJECT_BY_UUID[task_uuid]
|
|
if inject['exercise_uuid'] not in db.SELECTED_EXERCISES:
|
|
continue
|
|
logger.debug(f"[{task_uuid}] :: checking: {inject['name']}")
|
|
completed = await check_inject(user_id, inject, data, context)
|
|
if completed:
|
|
succeeded_once = True
|
|
return succeeded_once |