Compare commits

..

No commits in common. "202f7b7eb6bebd0dd428eb382283dfdd77f799f2" and "f236da0055838536c7dd8cf575e56bfa7cb827d1" have entirely different histories.

33 changed files with 1041 additions and 2390 deletions

2
.gitignore vendored
View file

@ -1,8 +1,6 @@
__pycache__ __pycache__
venv venv
config.py
misp_cache.sqlite misp_cache.sqlite
backup.json
# Logs # Logs
logs logs

View file

@ -8,15 +8,12 @@ source venv/bin/activate
# Install deps # Install deps
pip3 install -r REQUIREMENTS pip3 install -r REQUIREMENTS
# Create config file and adapt it to your needs
cp config.py.sample config.py
``` ```
## Running the PROD setup ## Running the PROD setup
```bash ```bash
python3 server.py python3 server.py
# Access the page http://localhost:4000 with your browser # Access the page http://localhost:3000 with your browser
``` ```

View file

@ -1,6 +1,6 @@
pyzmq pyzmq
python-socketio python-socketio
aiohttp eventlet
requests requests
requests-cache requests-cache
jq jq

View file

@ -10,7 +10,6 @@ misp_skipssl = True
live_logs_accepted_scope = { live_logs_accepted_scope = {
'events': ['add', 'edit', 'delete', 'restSearch',], 'events': ['add', 'edit', 'delete', 'restSearch',],
'attributes': ['add', 'edit', 'delete', 'restSearch',], 'attributes': ['add', 'edit', 'delete', 'restSearch',],
'eventReports': ['add', 'edit', 'delete',],
'tags': '*', 'tags': '*',
} }
@ -19,6 +18,7 @@ logger = logging.getLogger('misp-exercise-dashboard')
format = '[%(levelname)s] %(asctime)s - %(message)s' format = '[%(levelname)s] %(asctime)s - %(message)s'
formatter = logging.Formatter(format) formatter = logging.Formatter(format)
logging.basicConfig(filename='misp-exercise-dashboard.log', encoding='utf-8', level=logging.DEBUG, format=format) logging.basicConfig(filename='misp-exercise-dashboard.log', encoding='utf-8', level=logging.DEBUG, format=format)
# create console handler and set level to debug
ch = logging.StreamHandler() ch = logging.StreamHandler()
ch.setLevel(logging.INFO) ch.setLevel(logging.INFO)
ch.setFormatter(formatter) ch.setFormatter(formatter)

36
db.py
View file

@ -5,49 +5,17 @@ import collections
USER_ID_TO_EMAIL_MAPPING = {} USER_ID_TO_EMAIL_MAPPING = {}
USER_ID_TO_AUTHKEY_MAPPING = {} USER_ID_TO_AUTHKEY_MAPPING = {}
ALL_EXERCISES = [] ALL_EXERCISES = []
SELECTED_EXERCISES = [] SELECTED_EXERCISES = []
INJECT_BY_UUID = {} INJECT_BY_UUID = {}
INJECT_SEQUENCE_BY_INJECT_UUID = {} INJECT_SEQUENCE_BY_INJECT_UUID = {}
INJECT_REQUIREMENTS_BY_INJECT_UUID = {} INJECT_REQUIREMENTS_BY_INJECT_UUID = {}
EXERCISES_STATUS = {} EXERCISES_STATUS = {}
PROGRESS = {
}
NOTIFICATION_BUFFER_SIZE = 30 NOTIFICATION_BUFFER_SIZE = 30
NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE) NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE)
NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN = 12
NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN = 20
NOTIFICATION_HISTORY_FREQUENCY = 60 / NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN
notification_history_buffer_size = NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN * NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN
NOTIFICATION_HISTORY = collections.deque([], notification_history_buffer_size)
NOTIFICATION_HISTORY.extend([0] * notification_history_buffer_size)
USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN = 2
USER_ACTIVITY_TIMESPAN_MIN = 20
USER_ACTIVITY_FREQUENCY = 60 / USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN
USER_ACTIVITY = {}
user_activity_buffer_size = USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN * USER_ACTIVITY_TIMESPAN_MIN
def resetNotificationMessage(): def resetNotificationMessage():
global NOTIFICATION_MESSAGES global NOTIFICATION_MESSAGES
NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE) NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_BUFFER_SIZE)
def resetNotificationHistory():
global NOTIFICATION_HISTORY
NOTIFICATION_HISTORY = collections.deque([], notification_history_buffer_size)
NOTIFICATION_HISTORY.extend([0] * notification_history_buffer_size)
def addUserActivity(user_id: int, count: int):
global USER_ACTIVITY, USER_ACTIVITY_TIMESPAN_MIN
if user_id not in USER_ACTIVITY:
USER_ACTIVITY[user_id] = collections.deque([], user_activity_buffer_size)
USER_ACTIVITY[user_id].extend([0] * user_activity_buffer_size)
USER_ACTIVITY[user_id].append(count)
def resetUserActivity():
for user_id in USER_ACTIVITY.keys():
USER_ACTIVITY[user_id] = collections.deque([], user_activity_buffer_size)
USER_ACTIVITY[user_id].extend([0] * user_activity_buffer_size)

File diff suppressed because one or more lines are too long

778
dist/assets/index-Bk6s3GdT.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-DlglK08D.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -5,8 +5,8 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Vite App</title>
<script type="module" crossorigin src="/assets/index-BbC5Fp4k.js"></script> <script type="module" crossorigin src="/assets/index-Bk6s3GdT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XAPeN3Gs.css"> <link rel="stylesheet" crossorigin href="/assets/index-DlglK08D.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -2,6 +2,7 @@
import functools import functools
import time import time
from collections import defaultdict
from pathlib import Path from pathlib import Path
import json import json
import re import re
@ -9,7 +10,7 @@ from typing import Union
import jq import jq
import db import db
from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search from inject_evaluator import eval_data_filtering, eval_query_comparison
import misp_api import misp_api
import config import config
from config import logger from config import logger
@ -60,29 +61,6 @@ def read_exercise_dir():
return exercises return exercises
def backup_exercises_progress():
with open('backup.json', 'w') as f:
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,
}
json.dump(toBackup, f)
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 = data['USER_ID_TO_EMAIL_MAPPING']
db.USER_ID_TO_AUTHKEY_MAPPING = data['USER_ID_TO_AUTHKEY_MAPPING']
except:
logger.info('Could not restore exercise progress')
def is_validate_exercises(exercises: list) -> bool: def is_validate_exercises(exercises: list) -> bool:
exercises_uuid = set() exercises_uuid = set()
tasks_uuid = set() tasks_uuid = set()
@ -162,14 +140,11 @@ def get_exercises():
tasks = [] tasks = []
for inject in exercise['injects']: for inject in exercise['injects']:
score = db.EXERCISES_STATUS[exercise['exercise']['uuid']]['tasks'][inject['uuid']]['score'] score = db.EXERCISES_STATUS[exercise['exercise']['uuid']]['tasks'][inject['uuid']]['score']
requirements = db.INJECT_REQUIREMENTS_BY_INJECT_UUID[inject['uuid']]
tasks.append( tasks.append(
{ {
"name": inject['name'], "name": inject['name'],
"uuid": inject['uuid'], "uuid": inject['uuid'],
"description": inject.get('description', ''),
"score": score, "score": score,
"requirements": requirements,
} }
) )
exercises.append( exercises.append(
@ -204,7 +179,6 @@ def resetAllExerciseProgress():
for exercise_status in db.EXERCISES_STATUS.values(): for exercise_status in db.EXERCISES_STATUS.values():
for task in exercise_status['tasks'].values(): for task in exercise_status['tasks'].values():
mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid']) mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid'])
backup_exercises_progress()
def get_completed_tasks_for_user(user_id: int): def get_completed_tasks_for_user(user_id: int):
@ -273,9 +247,8 @@ def get_completion_for_users():
for task in exercise_status['tasks'].values(): for task in exercise_status['tasks'].values():
for user_id in completion_per_user.keys(): for user_id in completion_per_user.keys():
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
for entry in task['completed_by_user']: for user_id in task['completed_by_user']:
user_id = entry['user_id'] completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = True
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
return completion_per_user return completion_per_user
@ -295,27 +268,13 @@ def get_score_for_task_completion(tasks_completion: dict) -> int:
def mark_task_completed(user_id: int, exercise_uuid: str , task_uuid: str): 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 user_id not in 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)
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): 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'])) if user_id in 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 db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'].remove(user_id)
def get_progress(): def get_progress():
@ -335,9 +294,9 @@ def get_progress():
return progress return progress
async def check_inject(user_id: int, inject: dict, data: dict, context: dict) -> bool: def check_inject(user_id: int, inject: dict, data: dict, context: dict) -> bool:
for inject_evaluation in inject['inject_evaluation']: for inject_evaluation in inject['inject_evaluation']:
success = await inject_checker_router(user_id, inject_evaluation, data, context) success = inject_checker_router(user_id, inject_evaluation, data, context)
if not success: if not success:
logger.info(f"Task not completed: {inject['uuid']}") logger.info(f"Task not completed: {inject['uuid']}")
return False return False
@ -361,39 +320,35 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
return False return False
return False return False
async def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool: 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): if not is_valid_evaluation_context(user_id, inject_evaluation, data, context):
return False return False
if 'evaluation_strategy' not in inject_evaluation: if 'evaluation_strategy' not in inject_evaluation:
return False return False
data_to_validate = await get_data_to_validate(user_id, inject_evaluation, data) data_to_validate = get_data_to_validate(user_id, inject_evaluation, data)
if data_to_validate is None: if data_to_validate is None:
logger.debug('Could not fetch data to validate') logger.debug('Could not fetch data to validate')
return False return False
if inject_evaluation['evaluation_strategy'] == 'data_filtering': if inject_evaluation['evaluation_strategy'] == 'data_filtering':
return eval_data_filtering(user_id, inject_evaluation, data_to_validate, context) return eval_data_filtering(user_id, inject_evaluation, data_to_validate)
elif inject_evaluation['evaluation_strategy'] == 'query_mirror': elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
expected_data = data_to_validate['expected_data'] expected_data = data_to_validate['expected_data']
data_to_validate = data_to_validate['data_to_validate'] data_to_validate = data_to_validate['data_to_validate']
return eval_query_mirror(user_id, expected_data, data_to_validate, context) return eval_query_comparison(user_id, expected_data, data_to_validate)
elif inject_evaluation['evaluation_strategy'] == 'query_search':
return eval_query_search(user_id, inject_evaluation, data_to_validate, context)
return False return False
async def get_data_to_validate(user_id: int, inject_evaluation: dict, data: dict) -> Union[dict, list, str, None]: def get_data_to_validate(user_id: int, inject_evaluation: dict, data: dict) -> Union[dict, list, str, None]:
data_to_validate = None data_to_validate = None
if inject_evaluation['evaluation_strategy'] == 'data_filtering': if inject_evaluation['evaluation_strategy'] == 'data_filtering':
event_id = parse_event_id_from_log(data) event_id = parse_event_id_from_log(data)
data_to_validate = await fetch_data_for_data_filtering(event_id=event_id) data_to_validate = fetch_data_for_data_filtering(event_id=event_id)
elif inject_evaluation['evaluation_strategy'] == 'query_mirror': elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
perfomed_query = parse_performed_query_from_log(data) perfomed_query = parse_performed_query_from_log(data)
data_to_validate = await fetch_data_for_query_mirror(user_id, inject_evaluation, perfomed_query) data_to_validate = fetch_data_for_query_comparison(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 return data_to_validate
@ -439,14 +394,14 @@ def parse_performed_query_from_log(data: dict) -> Union[dict, None]:
return None return None
async def fetch_data_for_data_filtering(event_id=None) -> Union[None, dict]: def fetch_data_for_data_filtering(event_id=None) -> Union[None, dict]:
data = None data = None
if event_id is not None: if event_id is not None:
data = await misp_api.getEvent(event_id) data = misp_api.getEvent(event_id)
return data return data
async def fetch_data_for_query_mirror(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]: def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]:
data = None data = None
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id] authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
if perfomed_query is not None: if perfomed_query is not None:
@ -456,8 +411,8 @@ async def fetch_data_for_query_mirror(user_id: int, inject_evaluation: dict, per
expected_method = query_context['request_method'] expected_method = query_context['request_method']
expected_url = query_context['url'] expected_url = query_context['url']
expected_payload = inject_evaluation['parameters'][0] expected_payload = inject_evaluation['parameters'][0]
expected_data = await misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload) expected_data = 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_to_validate = misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload'])
data = { data = {
'expected_data' : expected_data, 'expected_data' : expected_data,
'data_to_validate' : data_to_validate, 'data_to_validate' : data_to_validate,
@ -465,20 +420,8 @@ async def fetch_data_for_query_mirror(user_id: int, inject_evaluation: dict, per
return data 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) @debounce_check_active_tasks(debounce_seconds=2)
async def check_active_tasks(user_id: int, data: dict, context: dict) -> bool: def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
succeeded_once = False succeeded_once = False
available_tasks = get_available_tasks_for_user(user_id) available_tasks = get_available_tasks_for_user(user_id)
for task_uuid in available_tasks: for task_uuid in available_tasks:
@ -486,7 +429,7 @@ async def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
if inject['exercise_uuid'] not in db.SELECTED_EXERCISES: if inject['exercise_uuid'] not in db.SELECTED_EXERCISES:
continue continue
logger.debug(f"[{task_uuid}] :: checking: {inject['name']}") logger.debug(f"[{task_uuid}] :: checking: {inject['name']}")
completed = await check_inject(user_id, inject, data, context) completed = check_inject(user_id, inject, data, context)
if completed: if completed:
succeeded_once = True succeeded_once = True
return succeeded_once return succeeded_once

View file

@ -138,15 +138,7 @@
{ {
"parameters": [ "parameters": [
{ {
".response[].Event.event_creator_email": { ".Event.info": {
"comparison": "equals",
"values": [
"{{user_email}}"
]
}
},
{
".response[].Event.info": {
"comparison": "contains", "comparison": "contains",
"values": [ "values": [
"event", "event",
@ -156,17 +148,9 @@
} }
], ],
"result": "MISP Event created", "result": "MISP Event created",
"evaluation_strategy": "query_search", "evaluation_strategy": "data_filtering",
"evaluation_context": { "evaluation_context": {
"request_is_rest": true, "request_is_rest": true
"query_context": {
"url": "/events/restSearch",
"request_method": "POST",
"payload": {
"timestamp": "10d",
"eventinfo": "%API%"
}
}
}, },
"score_range": [ "score_range": [
0, 0,

View file

@ -119,7 +119,7 @@
} }
], ],
"result": "Published 48h retreived", "result": "Published 48h retreived",
"evaluation_strategy": "query_mirror", "evaluation_strategy": "query_comparison",
"evaluation_context": { "evaluation_context": {
"request_is_rest": true, "request_is_rest": true,
"query_context": { "query_context": {
@ -134,7 +134,7 @@
} }
], ],
"name": "Get Published in the past 48h", "name": "Get Published in the past 48h",
"target_tool": "MISP", "target_tool": "MISP-query",
"uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4" "uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4"
}, },
{ {
@ -150,7 +150,7 @@
} }
], ],
"result": "IP CSV retrieved", "result": "IP CSV retrieved",
"evaluation_strategy": "query_mirror", "evaluation_strategy": "query_comparison",
"evaluation_context": { "evaluation_context": {
"request_is_rest": true, "request_is_rest": true,
"query_context": { "query_context": {
@ -165,7 +165,7 @@
} }
], ],
"name": "IP IoCs changed in the past 48h in CSV", "name": "IP IoCs changed in the past 48h in CSV",
"target_tool": "MISP", "target_tool": "MISP-query",
"uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba" "uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba"
}, },
{ {
@ -180,7 +180,7 @@
} }
], ],
"result": "20 Attribute tagged retrieved", "result": "20 Attribute tagged retrieved",
"evaluation_strategy": "query_mirror", "evaluation_strategy": "query_comparison",
"evaluation_context": { "evaluation_context": {
"request_is_rest": true, "request_is_rest": true,
"query_context": { "query_context": {
@ -195,7 +195,7 @@
} }
], ],
"name": "First 20 Attribute with TLP lower than `amber`", "name": "First 20 Attribute with TLP lower than `amber`",
"target_tool": "MISP", "target_tool": "MISP-query",
"uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88" "uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88"
}, },
{ {
@ -209,7 +209,7 @@
} }
], ],
"result": "Phising counted", "result": "Phising counted",
"evaluation_strategy": "query_mirror", "evaluation_strategy": "query_comparison",
"evaluation_context": { "evaluation_context": {
"request_is_rest": true, "request_is_rest": true,
"query_context": { "query_context": {
@ -224,7 +224,7 @@
} }
], ],
"name": "Event count with `Phishing - T1566` involved", "name": "Event count with `Phishing - T1566` involved",
"target_tool": "MISP", "target_tool": "MISP-query",
"uuid": "1da0fdc8-9d0d-4618-a811-66491e196833" "uuid": "1da0fdc8-9d0d-4618-a811-66491e196833"
} }
] ]

View file

@ -219,7 +219,6 @@
} }
], ],
"name": "Event Creation", "name": "Event Creation",
"description": "Create an Event containing `ransomware`",
"target_tool": "MISP", "target_tool": "MISP",
"uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae" "uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
}, },

View file

@ -6,6 +6,7 @@ import operator
from config import logger from config import logger
# .Event.Attribute[] | select(.value == "evil.exe") | .Tag
def jq_extract(path: str, data: dict, extract_type='first'): def jq_extract(path: str, data: dict, extract_type='first'):
query = jq.compile(path).input_value(data) query = jq.compile(path).input_value(data)
try: try:
@ -14,42 +15,28 @@ def jq_extract(path: str, data: dict, extract_type='first'):
return None return None
# Replace the substring `{{variable}}` by context[variable] in the provided string
def apply_replacement_from_context(string: str, context: dict) -> str:
replacement_regex = r"{{(\w+)}}"
if r'{{' not in string and r'}}' not in string:
return string
matches = re.fullmatch(replacement_regex, string, re.MULTILINE)
if not matches:
return string
subst_str = matches.groups()[0]
subst = str(context.get(subst_str, ''))
return re.sub(replacement_regex, subst, string)
## ##
## Data Filtering ## Data Filtering
## ##
def condition_satisfied(evaluation_config: dict, data_to_validate: Union[dict, list, str], context: dict) -> bool: def condition_satisfied(evaluation_config: dict, data_to_validate: Union[dict, list, str]) -> bool:
if type(data_to_validate) is bool: if type(data_to_validate) is bool:
data_to_validate = "1" if data_to_validate else "0" data_to_validate = "1" if data_to_validate else "0"
if type(data_to_validate) is str: if type(data_to_validate) is str:
return eval_condition_str(evaluation_config, data_to_validate, context) return eval_condition_str(evaluation_config, data_to_validate)
elif type(data_to_validate) is list: elif type(data_to_validate) is list:
return eval_condition_list(evaluation_config, data_to_validate, context) return eval_condition_list(evaluation_config, data_to_validate)
elif type(data_to_validate) is dict: elif type(data_to_validate) is dict:
# Not sure how we could have condition on this # Not sure how we could have condition on this
return eval_condition_dict(evaluation_config, data_to_validate, context) return eval_condition_dict(evaluation_config, data_to_validate)
return False return False
def eval_condition_str(evaluation_config: dict, data_to_validate: str, context: dict) -> bool: def eval_condition_str(evaluation_config: dict, data_to_validate: str) -> bool:
comparison_type = evaluation_config['comparison'] comparison_type = evaluation_config['comparison']
values = evaluation_config['values'] values = evaluation_config['values']
if len(values) == 0: if len(values) == 0:
return False return False
values = [apply_replacement_from_context(v, context) for v in values]
if comparison_type == 'contains': if comparison_type == 'contains':
values = [v.lower() for v in values] values = [v.lower() for v in values]
@ -69,7 +56,7 @@ def eval_condition_str(evaluation_config: dict, data_to_validate: str, context:
return False return False
def eval_condition_list(evaluation_config: dict, data_to_validate: str, context: dict) -> bool: def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
comparison_type = evaluation_config['comparison'] comparison_type = evaluation_config['comparison']
values = evaluation_config['values'] values = evaluation_config['values']
comparators = { comparators = {
@ -82,7 +69,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str, context:
if len(values) == 0: if len(values) == 0:
return False return False
values = [apply_replacement_from_context(v, context) for v in values]
if comparison_type == 'contains' or comparison_type == 'equals': if comparison_type == 'contains' or comparison_type == 'equals':
data_to_validate_set = set(data_to_validate) data_to_validate_set = set(data_to_validate)
@ -115,7 +102,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str, context:
return False return False
def eval_condition_dict(evaluation_config: dict, data_to_validate: str, context: dict) -> bool: def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
comparison_type = evaluation_config['comparison'] comparison_type = evaluation_config['comparison']
values = evaluation_config['values'] values = evaluation_config['values']
comparators = { comparators = {
@ -126,10 +113,6 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str, context:
'=': operator.eq, '=': operator.eq,
} }
if len(values) == 0:
return False
values = [apply_replacement_from_context(v, context) for v in values]
comparison_type = evaluation_config['comparison'] comparison_type = evaluation_config['comparison']
if comparison_type == 'contains': if comparison_type == 'contains':
pass pass
@ -146,31 +129,21 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str, context:
return False return False
def eval_data_filtering(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool: def eval_data_filtering(user_id: int, inject_evaluation: dict, data: dict) -> bool:
for evaluation_params in inject_evaluation['parameters']: for evaluation_params in inject_evaluation['parameters']:
for evaluation_path, evaluation_config in evaluation_params.items(): for evaluation_path, evaluation_config in evaluation_params.items():
evaluation_path = apply_replacement_from_context(evaluation_path, context)
data_to_validate = jq_extract(evaluation_path, data, evaluation_config.get('extract_type', 'first')) data_to_validate = jq_extract(evaluation_path, data, evaluation_config.get('extract_type', 'first'))
if data_to_validate is None: if data_to_validate is None:
logger.debug('Could not extract data') logger.debug('Could not extract data')
return False return False
if not condition_satisfied(evaluation_config, data_to_validate, context): if not condition_satisfied(evaluation_config, data_to_validate):
return False return False
return True return True
## ##
## Query mirror ## Query comparison
## ##
def eval_query_mirror(user_id: int, expected_data, data_to_validate, context: dict) -> bool: def eval_query_comparison(user_id: int, expected_data, data_to_validate) -> bool:
return expected_data == data_to_validate return expected_data == data_to_validate
##
## Query search
##
def eval_query_search(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
return eval_data_filtering(user_id, inject_evaluation, data, context)

View file

@ -4,7 +4,6 @@ import json
from datetime import timedelta from datetime import timedelta
from typing import Union from typing import Union
from urllib.parse import urljoin from urllib.parse import urljoin
import asyncio
import requests # type: ignore import requests # type: ignore
import requests.adapters # type: ignore import requests.adapters # type: ignore
from requests_cache import CachedSession from requests_cache import CachedSession
@ -19,7 +18,7 @@ requestSession.mount('https://', adapterCache)
requestSession.mount('http://', adapterCache) requestSession.mount('http://', adapterCache)
async def get(url, data={}, api_key=misp_apikey): def get(url, data={}, api_key=misp_apikey):
headers = { headers = {
'User-Agent': 'misp-exercise-dashboard', 'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key, "Authorization": api_key,
@ -28,22 +27,17 @@ async def get(url, data={}, api_key=misp_apikey):
} }
full_url = urljoin(misp_url, url) full_url = urljoin(misp_url, url)
try: try:
loop = asyncio.get_event_loop() response = requestSession.get(full_url, data=data, headers=headers, verify=not misp_skipssl)
job = lambda: requestSession.get(full_url, data=data, headers=headers, verify=not misp_skipssl)
runningJob = loop.run_in_executor(None, job)
response = await runningJob
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
logger.info('Could not perform request on MISP. %s', e) logger.info('Could not perform request on MISP. %s', e)
return None return None
except Exception as e:
logger.warning('Could not perform request on MISP. %s', e)
try: try:
return response.json() if response.headers['content-type'].startswith('application/json') else response.text return response.json() if response.headers['content-type'].startswith('application/json') else response.text
except requests.exceptions.JSONDecodeError: except requests.exceptions.JSONDecodeError:
return response.text return response.text
async def post(url, data={}, api_key=misp_apikey): def post(url, data={}, api_key=misp_apikey):
headers = { headers = {
'User-Agent': 'misp-exercise-dashboard', 'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key, "Authorization": api_key,
@ -52,37 +46,32 @@ async def post(url, data={}, api_key=misp_apikey):
} }
full_url = urljoin(misp_url, url) full_url = urljoin(misp_url, url)
try: try:
loop = asyncio.get_event_loop() response = requestSession.post(full_url, data=json.dumps(data), headers=headers, verify=not misp_skipssl)
job = lambda: requestSession.post(full_url, data=json.dumps(data), headers=headers, verify=not misp_skipssl)
runningJob = loop.run_in_executor(None, job)
response = await runningJob
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
logger.info('Could not perform request on MISP. %s', e) logger.info('Could not perform request on MISP. %s', e)
return None return None
except Exception as e:
logger.warning('Could not perform request on MISP. %s', e)
try: try:
return response.json() if response.headers['content-type'].startswith('application/json') else response.text return response.json() if response.headers['content-type'].startswith('application/json') else response.text
except requests.exceptions.JSONDecodeError: except requests.exceptions.JSONDecodeError:
return response.text return response.text
async def getEvent(event_id: int) -> Union[None, dict]: def getEvent(event_id: int) -> Union[None, dict]:
return await get(f'/events/view/{event_id}') return get(f'/events/view/{event_id}')
async def doRestQuery(authkey: str, request_method: str, url: str, payload: dict = {}) -> Union[None, dict]: def doRestQuery(authkey: str, request_method: str, url: str, payload: dict = {}) -> Union[None, dict]:
if request_method == 'POST': if request_method == 'POST':
return await post(url, payload, api_key=authkey) return post(url, payload, api_key=authkey)
else: else:
return await get(url, payload, api_key=authkey) return get(url, payload, api_key=authkey)
async def getVersion() -> Union[None, dict]: def getVersion() -> Union[None, dict]:
return await get(f'/servers/getVersion.json') return get(f'/servers/getVersion.json')
async def getSettings() -> Union[None, dict]: def getSettings() -> Union[None, dict]:
SETTING_TO_QUERY = [ SETTING_TO_QUERY = [
'Plugin.ZeroMQ_enable', 'Plugin.ZeroMQ_enable',
'Plugin.ZeroMQ_audit_notifications_enable', 'Plugin.ZeroMQ_audit_notifications_enable',
@ -94,7 +83,7 @@ async def getSettings() -> Union[None, dict]:
'MISP.log_auth', 'MISP.log_auth',
'Security.allow_unsafe_cleartext_apikey_logging', 'Security.allow_unsafe_cleartext_apikey_logging',
] ]
settings = await get(f'/servers/serverSettings.json') settings = get(f'/servers/serverSettings.json')
if not settings: if not settings:
return None return None
return { return {

View file

@ -9,7 +9,6 @@ from urllib.parse import parse_qs
VERBOSE_MODE = False VERBOSE_MODE = False
APIQUERY_MODE = False
NOTIFICATION_COUNT = 1 NOTIFICATION_COUNT = 1
@ -18,39 +17,10 @@ def set_verbose_mode(enabled: bool):
VERBOSE_MODE = enabled VERBOSE_MODE = enabled
def set_apiquery_mode(enabled: bool):
global APIQUERY_MODE
APIQUERY_MODE = enabled
def get_notifications() -> list[dict]: def get_notifications() -> list[dict]:
return list(db.NOTIFICATION_MESSAGES) return list(db.NOTIFICATION_MESSAGES)
def get_notifications_history() -> dict:
return {
'history': list(db.NOTIFICATION_HISTORY),
'config': {
'buffer_resolution_per_minute': db.NOTIFICATION_HISTORY_BUFFER_RESOLUTION_PER_MIN,
'buffer_timestamp_min': db.NOTIFICATION_HISTORY_BUFFER_TIMESPAN_MIN,
'frequency': db.NOTIFICATION_HISTORY_FREQUENCY,
'notification_history_size': db.notification_history_buffer_size,
},
}
def get_users_activity() -> dict:
return {
'activity': {user_id: list(activity) for user_id, activity in db.USER_ACTIVITY.items()},
'config': {
'timestamp_min': db.USER_ACTIVITY_TIMESPAN_MIN,
'buffer_resolution_per_minute': db.USER_ACTIVITY_BUFFER_RESOLUTION_PER_MIN,
'frequency': db.USER_ACTIVITY_FREQUENCY,
'activity_buffer_size': db.user_activity_buffer_size,
},
}
def reset_notifications(): def reset_notifications():
db.resetNotificationMessage() db.resetNotificationMessage()
@ -59,14 +29,6 @@ def record_notification(notification: dict):
db.NOTIFICATION_MESSAGES.appendleft(notification) db.NOTIFICATION_MESSAGES.appendleft(notification)
def record_notification_history(message_count: int):
db.NOTIFICATION_HISTORY.append(message_count)
def record_user_activity(user_id: int, count: int):
db.addUserActivity(user_id, count)
def get_user_id(data: dict): def get_user_id(data: dict):
if 'user_id' in data: if 'user_id' in data:
return int(data['user_id']) return int(data['user_id'])
@ -186,8 +148,6 @@ def is_accepted_notification(notification) -> bool:
return False return False
if VERBOSE_MODE: if VERBOSE_MODE:
return True return True
if APIQUERY_MODE and not notification['is_api_request']:
return False
if '@' not in notification['user']: # Ignore message from system if '@' not in notification['user']: # Ignore message from system
return False return False

126
package-lock.json generated
View file

@ -13,10 +13,8 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "^3.0.8",
"apexcharts": "^3.49.2",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"vue": "^3.4.29", "vue": "^3.4.29"
"vue3-apexcharts": "^1.5.3"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.8.0", "@rushstack/eslint-patch": "^1.8.0",
@ -1079,12 +1077,6 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz",
"integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA==" "integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA=="
}, },
"node_modules/@yr/monotone-cubic-spline": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==",
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.12.0", "version": "8.12.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
@ -1165,21 +1157,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/apexcharts": {
"version": "3.49.2",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.2.tgz",
"integrity": "sha512-vBB8KgwfD9rSObA7s4kY2rU6DeaN67gTR3JN7r32ztgKVf8lKkdFQ6iUhk6oIHrV7W8PoHhr5EwKymn0z5Fz6A==",
"license": "MIT",
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3",
"svg.draggable.js": "^2.2.2",
"svg.easing.js": "^2.0.0",
"svg.filter.js": "^2.0.2",
"svg.pathmorphing.js": "^0.1.3",
"svg.resize.js": "^1.4.3",
"svg.select.js": "^3.0.1"
}
},
"node_modules/arg": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -3315,97 +3292,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg.draggable.js": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
"integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.easing.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
"integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==",
"license": "MIT",
"dependencies": {
"svg.js": ">=2.3.x"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.filter.js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
"integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.js": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
"integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==",
"license": "MIT"
},
"node_modules/svg.pathmorphing.js": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
"integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
"integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.6.5",
"svg.select.js": "^2.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js/node_modules/svg.select.js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.select.js": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
"integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
"license": "MIT",
"dependencies": {
"svg.js": "^2.6.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/synckit": { "node_modules/synckit": {
"version": "0.8.8", "version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
@ -3678,16 +3564,6 @@
"eslint": ">=6.0.0" "eslint": ">=6.0.0"
} }
}, },
"node_modules/vue3-apexcharts": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.5.3.tgz",
"integrity": "sha512-yaHTPoj0iVKAtEVg8wEwIwwvf0VG+lPYNufCf3txRzYQOqdKPoZaZ9P3Dj3X+2A1XY9O1kcTk9HVqvLo+rppvQ==",
"license": "MIT",
"peerDependencies": {
"apexcharts": "> 3.0.0",
"vue": "> 3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -16,10 +16,8 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "^3.0.8",
"apexcharts": "^3.49.2",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"vue": "^3.4.29", "vue": "^3.4.29"
"vue3-apexcharts": "^1.5.3"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.8.0", "@rushstack/eslint-patch": "^1.8.0",

169
server.py
View file

@ -1,14 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import collections
import functools import functools
import json import json
import sys import sys
import time import time
import zmq import zmq
import socketio import socketio
from aiohttp import web import eventlet
import zmq.asyncio from eventlet.green import zmq as gzmq
import exercise as exercise_model import exercise as exercise_model
import notification as notification_model import notification as notification_model
@ -18,10 +17,7 @@ from config import logger
import misp_api import misp_api
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
ZMQ_MESSAGE_COUNT = 0 ZMQ_MESSAGE_COUNT = 0
ZMQ_LAST_TIME = None
USER_ACTIVITY = collections.defaultdict(int)
def debounce(debounce_seconds: int = 1): def debounce(debounce_seconds: int = 1):
@ -45,91 +41,77 @@ def debounce(debounce_seconds: int = 1):
# Initialize ZeroMQ context and subscriber socket # Initialize ZeroMQ context and subscriber socket
context = zmq.asyncio.Context() context = gzmq.Context()
zsocket = context.socket(zmq.SUB) zsocket = context.socket(gzmq.SUB)
zmq_url = config.zmq_url zmq_url = config.zmq_url
zsocket.connect(zmq_url) zsocket.connect(zmq_url)
zsocket.setsockopt_string(zmq.SUBSCRIBE, '') zsocket.setsockopt_string(gzmq.SUBSCRIBE, '')
# Initialize Socket.IO server # Initialize Socket.IO server
sio = socketio.AsyncServer(cors_allowed_origins='*', async_mode='aiohttp') sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
app = web.Application() app = socketio.WSGIApp(sio, static_files={
sio.attach(app) '/': {'content_type': 'text/html', 'filename': 'dist/index.html'},
'/assets': './dist/assets',
})
async def index(request):
with open('dist/index.html') as f:
return web.Response(text=f.read(), content_type='text/html')
@sio.event @sio.event
async def connect(sid, environ): def connect(sid, environ):
logger.debug("Client connected: %s", sid) logger.debug("Client connected: %s", sid)
@sio.event @sio.event
async def disconnect(sid): def disconnect(sid):
logger.debug("Client disconnected: %s", sid) logger.debug("Client disconnected: %s", sid)
@sio.event @sio.event
async def get_exercises(sid): def get_exercises(sid):
return exercise_model.get_exercises() return exercise_model.get_exercises()
@sio.event @sio.event
async def get_selected_exercises(sid): def get_selected_exercises(sid):
return exercise_model.get_selected_exercises() return exercise_model.get_selected_exercises()
@sio.event @sio.event
async def change_exercise_selection(sid, payload): def change_exercise_selection(sid, payload):
return exercise_model.change_exercise_selection(payload['exercise_uuid'], payload['selected']) return exercise_model.change_exercise_selection(payload['exercise_uuid'], payload['selected'])
@sio.event @sio.event
async def get_progress(sid): def get_progress(sid):
return exercise_model.get_progress() return exercise_model.get_progress()
@sio.event @sio.event
async def get_notifications(sid): def get_notifications(sid):
return notification_model.get_notifications() return notification_model.get_notifications()
@sio.event @sio.event
async def mark_task_completed(sid, payload): def mark_task_completed(sid, payload):
return exercise_model.mark_task_completed(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid']) return exercise_model.mark_task_completed(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid'])
@sio.event @sio.event
async def mark_task_incomplete(sid, payload): def mark_task_incomplete(sid, payload):
return exercise_model.mark_task_incomplete(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid']) return exercise_model.mark_task_incomplete(int(payload['user_id']), payload['exercise_uuid'], payload['task_uuid'])
@sio.event @sio.event
async def reset_all_exercise_progress(sid): def reset_all_exercise_progress(sid):
return exercise_model.resetAllExerciseProgress() return exercise_model.resetAllExerciseProgress()
@sio.event @sio.event
async def reset_notifications(sid): def reset_notifications(sid):
return notification_model.reset_notifications() return notification_model.reset_notifications()
@sio.event @sio.event
async def get_diagnostic(sid): def get_diagnostic(sid):
return await getDiagnostic() return getDiagnostic()
@sio.event @sio.event
async def get_users_activity(sid): def toggle_verbose_mode(sid, payload):
return notification_model.get_users_activity()
@sio.event
async def toggle_verbose_mode(sid, payload):
return notification_model.set_verbose_mode(payload['verbose']) return notification_model.set_verbose_mode(payload['verbose'])
@sio.event
async def toggle_apiquery_mode(sid, payload):
return notification_model.set_apiquery_mode(payload['apiquery'])
@sio.on('*') @sio.on('*')
async def any_event(event, sid, data={}): def any_event(event, sid, data={}):
logger.info('>> Unhandled event %s', event) logger.info('>> Unhandled event %s', event)
async def handleMessage(topic, s, message): def handleMessage(topic, s, message):
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
data = json.loads(message) data = json.loads(message)
if topic == 'misp_json_audit': if topic == 'misp_json_audit':
@ -137,7 +119,7 @@ async def handleMessage(topic, s, message):
if user_id is not None and '@' in email: if user_id is not None and '@' in email:
if user_id not in db.USER_ID_TO_EMAIL_MAPPING: if user_id not in db.USER_ID_TO_EMAIL_MAPPING:
db.USER_ID_TO_EMAIL_MAPPING[user_id] = email db.USER_ID_TO_EMAIL_MAPPING[user_id] = email
await sio.emit('new_user', email) sio.emit('new_user', email)
user_id, authkey = notification_model.get_user_authkey_id_pair(data) user_id, authkey = notification_model.get_user_authkey_id_pair(data)
if user_id is not None: if user_id is not None:
@ -149,33 +131,24 @@ async def handleMessage(topic, s, message):
notification = notification_model.get_notification_message(data) notification = notification_model.get_notification_message(data)
if notification_model.is_accepted_notification(notification): if notification_model.is_accepted_notification(notification):
notification_model.record_notification(notification) notification_model.record_notification(notification)
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN += 1 sio.emit('notification', notification)
user_id = notification_model.get_user_id(data)
if user_id is not None:
USER_ACTIVITY[user_id] += 1
await sio.emit('notification', notification)
user_id = notification_model.get_user_id(data) user_id = notification_model.get_user_id(data)
if user_id is not None: if user_id is not None:
if exercise_model.is_accepted_query(data): if exercise_model.is_accepted_query(data):
context = get_context(topic, user_id, data) context = get_context(data)
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context) succeeded_once = exercise_model.check_active_tasks(user_id, data, context)
if succeeded_once: if succeeded_once:
await sendRefreshScore() sendRefreshScore()
@debounce(debounce_seconds=1) @debounce(debounce_seconds=1)
async def sendRefreshScore(): def sendRefreshScore():
await sio.emit('refresh_score') sio.emit('refresh_score')
def get_context(topic: str, user_id: int, data: dict) -> dict: def get_context(data: dict) -> dict:
context = { context = {}
'zmq_topic': topic,
'user_id': user_id,
'user_email': db.USER_ID_TO_EMAIL_MAPPING.get(user_id, None),
'user_authkey': db.USER_ID_TO_AUTHKEY_MAPPING.get(user_id, None),
}
if 'Log' in data: if 'Log' in data:
if 'request_is_rest' in data['Log']: if 'request_is_rest' in data['Log']:
context['request_is_rest'] = data['Log']['request_is_rest'] context['request_is_rest'] = data['Log']['request_is_rest']
@ -185,87 +158,35 @@ def get_context(topic: str, user_id: int, data: dict) -> dict:
return context return context
async def getDiagnostic() -> dict: def getDiagnostic() -> dict:
global ZMQ_MESSAGE_COUNT global ZMQ_MESSAGE_COUNT
diagnostic = {} diagnostic = {}
misp_version = await misp_api.getVersion() misp_version = misp_api.getVersion()
if misp_version is None: if misp_version is None:
diagnostic['online'] = False diagnostic['online'] = False
return diagnostic return diagnostic
diagnostic['version'] = misp_version diagnostic['version'] = misp_version
misp_settings = await misp_api.getSettings() misp_settings = misp_api.getSettings()
diagnostic['settings'] = misp_settings diagnostic['settings'] = misp_settings
diagnostic['zmq_message_count'] = ZMQ_MESSAGE_COUNT diagnostic['zmq_message_count'] = ZMQ_MESSAGE_COUNT
return diagnostic return diagnostic
async def notification_history():
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
while True:
await sio.sleep(db.NOTIFICATION_HISTORY_FREQUENCY)
notification_model.record_notification_history(ZMQ_MESSAGE_COUNT_LAST_TIMESPAN)
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
payload = notification_model.get_notifications_history()
await sio.emit('update_notification_history', payload)
async def record_users_activity():
global USER_ACTIVITY
while True:
await sio.sleep(db.USER_ACTIVITY_FREQUENCY)
for user_id, activity in USER_ACTIVITY.items():
notification_model.record_user_activity(user_id, activity)
USER_ACTIVITY[user_id] = 0
payload = notification_model.get_users_activity()
await sio.emit('update_users_activity', payload)
async def keepalive():
global ZMQ_LAST_TIME
while True:
await sio.sleep(5)
payload = {
'zmq_last_time': ZMQ_LAST_TIME,
}
await sio.emit('keep_alive', payload)
async def backup_exercises_progress():
while True:
await sio.sleep(5)
exercise_model.backup_exercises_progress()
# Function to forward zmq messages to Socket.IO # Function to forward zmq messages to Socket.IO
async def forward_zmq_to_socketio(): def forward_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT, ZMQ_LAST_TIME global ZMQ_MESSAGE_COUNT
while True: while True:
message = await zsocket.recv_string() message = zsocket.recv_string()
topic, s, m = message.partition(" ") topic, s, m = message.partition(" ")
await handleMessage(topic, s, m)
try: try:
ZMQ_MESSAGE_COUNT += 1 ZMQ_MESSAGE_COUNT += 1
ZMQ_LAST_TIME = time.time() handleMessage(topic, s, m)
# await handleMessage(topic, s, m)
except Exception as e: except Exception as e:
logger.error('Error handling message %s', e) logger.error('Error handling message %s', e)
async def init_app():
sio.start_background_task(forward_zmq_to_socketio)
sio.start_background_task(keepalive)
sio.start_background_task(notification_history)
sio.start_background_task(record_users_activity)
sio.start_background_task(backup_exercises_progress)
return app
app.router.add_static('/assets', 'dist/assets')
app.router.add_get('/', index)
if __name__ == "__main__": if __name__ == "__main__":
exercises_loaded = exercise_model.load_exercises() exercises_loaded = exercise_model.load_exercises()
@ -273,6 +194,8 @@ if __name__ == "__main__":
logger.critical('Could not load exercises') logger.critical('Could not load exercises')
sys.exit(1) sys.exit(1)
exercise_model.restore_exercices_progress() # Start the forwarding in a separate thread
eventlet.spawn_n(forward_zmq_to_socketio)
web.run_app(init_app(), host=config.server_host, port=config.server_port) # Run the Socket.IO server
eventlet.wsgi.server(eventlet.listen((config.server_host, config.server_port)), app)

View file

@ -5,20 +5,18 @@ import TheAdminPanel from './components/TheAdminPanel.vue'
import TheSocketConnectionState from './components/TheSocketConnectionState.vue' import TheSocketConnectionState from './components/TheSocketConnectionState.vue'
import TheDahboard from './TheDahboard.vue' import TheDahboard from './TheDahboard.vue'
import { socketConnected } from "@/socket"; import { socketConnected } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
onMounted(() => { onMounted(() => {
if (darkModeEnabled.value) {
document.getElementsByTagName('body')[0].classList.add('dark') document.getElementsByTagName('body')[0].classList.add('dark')
} document.getElementById('app').classList.add('w-5/6')
}) })
</script> </script>
<template> <template>
<main> <main>
<h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">Exercise Dashboard</h1> <h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">MISP Exercise Dashboard</h1>
<div class="absolute top-1 right-1"> <div class="absolute top-1 right-1">
<div class="flex gap-2"> <div class="flex gap-2">
<TheThemeButton></TheThemeButton> <TheThemeButton></TheThemeButton>
@ -39,12 +37,4 @@ body {
@apply dark:text-slate-300; @apply dark:text-slate-300;
} }
#app {
@apply 3xl:container mx-auto;
@apply mx-auto;
@apply mt-4;
@apply 3xl:w-11/12;
@apply lg:w-5/6;
}
</style> </style>

86
src/assets/base.css Normal file
View file

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -1,3 +1,20 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
/*
@media (min-width: 1024px) {
body {
display: flex;
}
#app {
display: flex;
}
} */

View file

@ -1,129 +0,0 @@
<script setup>
import { ref, watch, computed } from "vue"
import { userActivity, userActivityConfig } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
const props = defineProps(['user_id'])
const theChart = ref(null)
const bufferSize = computed(() => userActivityConfig.value.activity_buffer_size)
const bufferSizeMin = computed(() => userActivityConfig.value.timestamp_min)
const chartInitSeries = Array.from(Array(bufferSize.value)).map(() => 0)
const hasActivity = computed(() => userActivity.value.length != 0)
const chartSeries = computed(() => {
return !hasActivity.value ? chartInitSeries : activitySeries.value
})
const activitySeries = computed(() => {
const data = userActivity.value[props.user_id] === undefined ? chartInitSeries : userActivity.value[props.user_id]
return [{data: Array.from(data)}]
})
const colorRanges = [1, 3, 5, 7, 9, 1000]
const chartOptions = computed(() => {
return {
chart: {
height: 12,
width: 224,
type: 'heatmap',
sparkline: {
enabled: true
},
animations: {
enabled: false,
easing: 'easeinout',
speed: 200,
},
},
dataLabels: {
enabled: false,
style: {
fontSize: '10px',
fontWeight: '400',
}
},
plotOptions: {
heatmap: {
radius: 2,
enableShades: false,
shadeIntensity: 0.5,
reverseNegativeShade: true,
distributed: false,
useFillColorAsStroke: false,
colorScale: {
ranges: [
{
from: 0,
to: colorRanges[0],
color: darkModeEnabled.value ? '#1e3a8a' : '#bfdbfe',
},
{
from: colorRanges[0] + 1,
to: colorRanges[1],
color: darkModeEnabled.value ? '#1d4ed8' : '#93c5fd',
},
{
from: colorRanges[1] + 1,
to: colorRanges[2],
color: darkModeEnabled.value ? '#2563eb' : '#60a5fa',
},
{
from: colorRanges[2] + 1,
to: colorRanges[3],
color: darkModeEnabled.value ? '#3b82f6' : '#3b82f6',
},
{
from: colorRanges[3] + 1,
to: colorRanges[4],
color: darkModeEnabled.value ? '#60a5fa' : '#2563eb',
},
{
from: colorRanges[4] + 1,
to: colorRanges[5],
color: darkModeEnabled.value ? '#93c5fd' : '#1d4ed8',
},
],
// inverse: false,
min: 0,
max: 1000
},
},
},
states: {
hover: {
filter: {
type: 'none',
}
},
active: {
filter: {
type: 'none',
}
},
},
grid: {
show: false,
},
legend: {
show: true,
},
stroke: {
width: 0,
},
tooltip: {
enabled: false,
},
}
})
</script>
<template>
<span
class="h-3 w-52"
:title="`Activity over ${bufferSizeMin}min`"
>
<apexchart type="heatmap" height="12" width="224" :options="chartOptions" :series="chartSeries"></apexchart>
</span>
</template>

View file

@ -1,22 +1,16 @@
<script setup> <script setup>
import { ref, watch, computed } from "vue" import { ref, watch } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket"; import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons' import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
const verbose = ref(false) const verbose = ref(false)
const api_query = ref(false)
watch(verbose, (newValue) => { watch(verbose, (newValue) => {
toggleVerboseMode(newValue == true) toggleVerboseMode(newValue == true)
}) })
watch(api_query, (newValue) => {
toggleApiQueryMode(newValue == true)
})
function getClassFromResponseCode(response_code) { function getClassFromResponseCode(response_code) {
if (String(response_code).startsWith('2')) { if (String(response_code).startsWith('2')) {
return 'text-green-500' return 'text-green-500'
@ -39,7 +33,7 @@
<span class="rounded-lg py-1 px-2 dark:bg-sky-700 bg-sky-400 text-slate-800 dark:text-slate-200"> <span class="rounded-lg py-1 px-2 dark:bg-sky-700 bg-sky-400 text-slate-800 dark:text-slate-200">
<span class="mr-1"> <span class="mr-1">
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon> <FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
Players: User online:
</span> </span>
<span class="font-bold">{{ userCount }}</span> <span class="font-bold">{{ userCount }}</span>
</span> </span>
@ -58,22 +52,13 @@
<span class="font-bold">{{ notificationAPICounter }}</span> <span class="font-bold">{{ notificationAPICounter }}</span>
</span> </span>
<span class="flex items-center"> <span class="flex items-center">
<label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300"> <label class="mr-1 flex items-center cursor-pointer">
<input type="checkbox" class="toggle toggle-warning [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/> <input type="checkbox" class="toggle toggle-success [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/>
Verbose Verbose
</label> </label>
</span> </span>
<span class="flex items-center">
<label class="mr-1 flex items-center cursor-pointer text-slate-700 dark:text-slate-300">
<input type="checkbox" class="toggle toggle-success [--fallback-su:#22c55e] mr-1" :checked="api_query" @change="api_query = !api_query"/>
<FontAwesomeIcon :icon="faCog" size="sm" :mask="faCloud" transform="shrink-7 left-1" class="mr-1"></FontAwesomeIcon>
API Queries
</label>
</span>
</div> </div>
<TheLiveLogsActivityGraphVue></TheLiveLogsActivityGraphVue>
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full"> <table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
<thead> <thead>
<tr class="font-medium dark:text-slate-200 text-slate-600 "> <tr class="font-medium dark:text-slate-200 text-slate-600 ">

View file

@ -1,83 +0,0 @@
<script setup>
import { ref, watch, computed } from "vue"
import { notificationHistory, notificationHistoryConfig } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
const theChart = ref(null)
const chartInitSeries = [
{data: Array.from(Array(12*20)).map(()=> 0)}
]
const hasActivity = computed(() => notificationHistory.value.length > 0)
const chartSeries = computed(() => {
return notificationHistory.value ? notificationHistorySeries.value : chartInitSeries.value
})
const notificationHistorySeries = computed(() => {
return [{data: Array.from(notificationHistory.value)}]
})
const chartOptions = computed(() => {
return {
chart: {
type: 'bar',
width: '100%',
height: 32,
sparkline: {
enabled: true
},
dropShadow: {
enabled: true,
enabledOnSeries: undefined,
top: 2,
left: 1,
blur: 2,
color: '#000',
opacity: darkModeEnabled.value ? 0.35 : 0.15
},
animations: {
enabled: false,
easing: 'easeinout',
speed: 200,
},
},
colors: [darkModeEnabled.value ? '#008ffb' : '#1f9eff'],
plotOptions: {
bar: {
columnWidth: '80%'
}
},
yaxis: {
min: 0,
labels: {
show: false,
}
},
tooltip: {
enabled: false,
},
}
})
</script>
<template>
<div class="my-2 --ml-1 bg-slate-50 dark:bg-slate-600 py-1 pl-1 pr-3 rounded-md relative flex flex-col">
<div :class="`${!hasActivity ? 'hidden' : 'absolute'} h-10 -mt-1 w-full z-40`">
<div class="text-xxs flex justify-between h-full items-center text-slate-500 dark:text-slate-300">
<span class="-rotate-90 w-8 -ml-3">- {{ notificationHistoryConfig.buffer_timestamp_min }}min</span>
<span class="-rotate-90 w-8 text-xs"></span>
<span class="-rotate-90 w-8 text-lg"></span>
<span class="-rotate-90 w-8 text-xs"></span>
<span class="-rotate-90 w-8 -mr-1.5">- 0min</span>
</div>
</div>
<i :class="['text-center text-slate-600 dark:text-slate-400', hasActivity ? 'hidden' : 'block']">
- No recorded activity -
</i>
<apexchart
ref="theChart" :class="hasActivity ? 'block' : 'absolute h-8 w-full'" height="32" width="100%"
:options="chartOptions"
:series="chartSeries"
></apexchart>
</div>
</template>

View file

@ -2,8 +2,7 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket"; import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons' import { faCheck, faTimes, faGraduationCap } from '@fortawesome/free-solid-svg-icons'
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
const collapsed_panels = ref([]) const collapsed_panels = ref([])
@ -64,11 +63,10 @@
<th <th
v-for="(task, task_index) in exercise.tasks" v-for="(task, task_index) in exercise.tasks"
:key="task.name" :key="task.name"
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top" class="border-b border-slate-100 dark:border-slate-700 p-3"
:title="task.description"
> >
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span> <span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500">Task {{ task_index + 1 }}</span>
<i class="text-center">{{ task.name }}</i> <i class="text-center">{{ task.name }}</i>
</div> </div>
</th> </th>
@ -86,58 +84,29 @@
</tr> </tr>
<template v-else> <template v-else>
<tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900"> <tr v-for="(progress, user_id) in progresses" :key="user_id" class="bg-slate-100 dark:bg-slate-900">
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative"> <td class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6">
<span class="flex flex-col max-w-60"> <span :title="user_id">
<span :title="user_id" class="text-nowrap inline-block leading-5 truncate"> <span class="text-lg font-bold font-mono">{{ progress.email.split('@')[0] }}</span>
<FontAwesomeIcon v-if="progress.exercises[exercise.uuid].score / progress.exercises[exercise.uuid].max_score == 1" :icon="faMedal" class="mr-1 text-amber-300"></FontAwesomeIcon> <span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</span>
<span class="text-lg font-bold font-mono leading-5 tracking-tight">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono tracking-tight">@{{ progress.email.split('@')[1] }}</span>
</span>
<LiveLogsUserActivityGraph :user_id="user_id"></LiveLogsUserActivityGraph>
</span> </span>
</td> </td>
<td <td
v-for="(task, task_index) in exercise.tasks" v-for="(task, task_index) in exercise.tasks"
:key="task_index" :key="task_index"
class="text-center border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-2" class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3"
> >
<span <span
class="select-none cursor-pointer flex justify-center content-center flex-wrap h-9" class="select-none cursor-pointer text-nowrap"
@click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], user_id, exercise.uuid, task.uuid)" @click="toggleCompleted(progress.exercises[exercise.uuid].tasks_completion[task.uuid], user_id, exercise.uuid, task.uuid)"
> >
<span class="flex flex-col">
<span class="text-nowrap">
<FontAwesomeIcon <FontAwesomeIcon
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]" :icon="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? faCheck : faTimes"
:icon="faCheck"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<FontAwesomeIcon
v-else-if="task.requirements?.inject_uuid !== undefined && !progress.exercises[exercise.uuid].tasks_completion[task.requirements.inject_uuid]"
title="All requirements for that task haven't been fullfilled yet"
:icon="faHourglassHalf"
:class="`text-lg ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/>
<FontAwesomeIcon
v-else
:icon="faTimes"
:class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`" :class="`text-xl ${progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'}`"
/> />
<small :class="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'"> (+{{ task.score }})</small> <small :class="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? 'dark:text-green-400 text-green-600' : 'dark:text-slate-500 text-slate-400'"> (+{{ task.score }})</small>
</span> </span>
<span class="text-sm leading-3">
<span
v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp"
:class="progress.exercises[exercise.uuid].tasks_completion[task.uuid].first_completion ? 'font-bold' : 'font-extralight'"
>
{{ (new Date(progress.exercises[exercise.uuid].tasks_completion[task.uuid].timestamp * 1000)).toTimeString().split(' ', 1)[0] }}
</span>
<span v-else></span>
</span>
</span>
</span>
</td> </td>
<td class="border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3"> <td class="border-b border-slate-100 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3">
<div class="flex w-full h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600" role="progressbar" :aria-valuenow="progress.exercises[exercise.uuid].score" :aria-valuemin="0" aria-valuemax="100"> <div class="flex w-full h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-neutral-600" role="progressbar" :aria-valuenow="progress.exercises[exercise.uuid].score" :aria-valuemin="0" aria-valuemax="100">
<div <div
class="flex flex-col justify-center rounded-full overflow-hidden bg-green-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-green-500 transition-width transition-slowest ease" class="flex flex-col justify-center rounded-full overflow-hidden bg-green-600 text-xs text-white text-center whitespace-nowrap transition duration-500 dark:bg-green-500 transition-width transition-slowest ease"

View file

@ -1,46 +1,16 @@
<script setup> <script setup>
import { ref, onMounted } from "vue" import { socketConnected } from "@/socket";
import { socketConnected, zmqLastTime } from "@/socket";
const zmqLastTimeSecond = ref('?')
function refreshLastTime() {
if (zmqLastTime.value !== false) {
zmqLastTimeSecond.value = parseInt(((new Date()).getTime() - zmqLastTime.value * 1000) / 1000)
} else {
zmqLastTimeSecond.value = '?'
}
}
onMounted(() => {
setInterval(() => {
refreshLastTime()
}, 1000)
})
</script> </script>
<template> <template>
<span class="flex flex-col justify-center mt-1">
<span :class="{ <span :class="{
'px-2 rounded-md inline-block w-48 leading-4': true, 'px-2 py-1 rounded-md inline-block w-48': true,
'text-slate-900 dark:text-slate-400': socketConnected, 'text-slate-900 dark:text-slate-400': socketConnected,
'text-slate-50 bg-red-600 px-2 py-1': !socketConnected, 'text-slate-50 bg-red-600': !socketConnected,
}"> }">
<span class="mr-1">Socket.IO:</span> <span class="mr-1">Socket.IO:</span>
<span v-show="socketConnected" class="font-semibold text-green-600 dark:text-green-400">Connected</span> <span v-show="socketConnected" class="font-semibold text-green-600 dark:text-green-400">Connected</span>
<span v-show="!socketConnected" class="font-semibold text-slate-50">Disconnected</span> <span v-show="!socketConnected" class="font-semibold text-slate-50">Disconnected</span>
</span> </span>
<span
v-if="socketConnected"
class="text-xs font-thin leading-3 inline-block text-center"
>
<template v-if="zmqLastTimeSecond == 0">
online
</template>
<template v-else>
Last keep-alive: {{ zmqLastTimeSecond }}s ago
</template>
</span>
</span>
</template> </template>

View file

@ -2,12 +2,10 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons' import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'
import { darkModeOn } from "@/settings.js"
const darkMode = ref(darkModeOn.value) const darkMode = ref(true)
watch(darkMode, (newValue) => { watch(darkMode, (newValue) => {
darkModeOn.value = newValue
if (newValue) { if (newValue) {
document.getElementsByTagName('body')[0].classList.add('dark') document.getElementsByTagName('body')[0].classList.add('dark')
} else { } else {

View file

@ -1,10 +1,6 @@
import './assets/main.css' import './assets/main.css'
import VueApexCharts from "vue3-apexcharts";
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
const app = createApp(App) createApp(App).mount('#app')
app.use(VueApexCharts)
app.mount('#app')

View file

@ -1,4 +0,0 @@
import { ref, computed } from 'vue'
export const darkModeOn = ref(true)
export const darkModeEnabled = computed(() => darkModeOn.value)

View file

@ -3,17 +3,13 @@ import { io } from "socket.io-client";
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
// "undefined" means the URL will be computed from the `window.location` object // "undefined" means the URL will be computed from the `window.location` object
const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:40001"; const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:4000";
const MAX_LIVE_LOG = 30 const MAX_LIVE_LOG = 30
const initial_state = { const initial_state = {
notificationEvents: [], notificationEvents: [],
notificationCounter: 0, notificationCounter: 0,
notificationAPICounter: 0, notificationAPICounter: 0,
notificationHistory: [],
notificationHistoryConfig: {},
userActivity: {},
userActivityConfig: {},
exercises: [], exercises: [],
selected_exercises: [], selected_exercises: [],
progresses: {}, progresses: {},
@ -22,8 +18,7 @@ const initial_state = {
const state = reactive({ ...initial_state }); const state = reactive({ ...initial_state });
const connectionState = reactive({ const connectionState = reactive({
connected: false, connected: false
zmq_last_time: false,
}) })
@ -44,12 +39,7 @@ export const notificationCounter = computed(() => state.notificationCounter)
export const notificationAPICounter = computed(() => state.notificationAPICounter) export const notificationAPICounter = computed(() => state.notificationAPICounter)
export const userCount = computed(() => Object.keys(state.progresses).length) export const userCount = computed(() => Object.keys(state.progresses).length)
export const diagnostic = computed(() => state.diagnostic) export const diagnostic = computed(() => state.diagnostic)
export const notificationHistory = computed(() => state.notificationHistory)
export const notificationHistoryConfig = computed(() => state.notificationHistoryConfig)
export const userActivity = computed(() => state.userActivity)
export const userActivityConfig = computed(() => state.userActivityConfig)
export const socketConnected = computed(() => connectionState.connected) export const socketConnected = computed(() => connectionState.connected)
export const zmqLastTime = computed(() => connectionState.zmq_last_time)
export function resetState() { export function resetState() {
Object.assign(state, initial_state); Object.assign(state, initial_state);
@ -60,7 +50,6 @@ export function fullReload() {
getSelectedExercises() getSelectedExercises()
getNotifications() getNotifications()
getProgress() getProgress()
getUsersActivity()
} }
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) { export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
@ -92,10 +81,6 @@ export function toggleVerboseMode(enabled) {
sendToggleVerboseMode(enabled) sendToggleVerboseMode(enabled)
} }
export function toggleApiQueryMode(enabled) {
sendToggleApiQueryMode(enabled)
}
export const debouncedGetProgress = debounce(getProgress, 200, {leading: true}) export const debouncedGetProgress = debounce(getProgress, 200, {leading: true})
export const debouncedGetDiangostic = debounce(getDiangostic, 1000, {leading: true}) export const debouncedGetDiangostic = debounce(getDiangostic, 1000, {leading: true})
@ -127,14 +112,6 @@ function getProgress() {
}) })
} }
function getUsersActivity() {
socket.emit("get_users_activity", (user_activity_bundle) => {
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
}
function getDiangostic() { function getDiangostic() {
state.diagnostic = {} state.diagnostic = {}
socket.emit("get_diagnostic", (diagnostic) => { socket.emit("get_diagnostic", (diagnostic) => {
@ -174,13 +151,6 @@ function sendToggleVerboseMode(enabled) {
socket.emit("toggle_verbose_mode", payload, () => {}) socket.emit("toggle_verbose_mode", payload, () => {})
} }
function sendToggleApiQueryMode(enabled) {
const payload = {
apiquery: enabled
}
socket.emit("toggle_apiquery_mode", payload, () => {})
}
/* Event listener */ /* Event listener */
socket.on("connect", () => { socket.on("connect", () => {
@ -207,20 +177,6 @@ socket.on("refresh_score", (new_user) => {
debouncedGetProgress() debouncedGetProgress()
}); });
socket.on("keep_alive", (keep_alive) => {
connectionState.zmq_last_time = keep_alive['zmq_last_time']
});
socket.on("update_notification_history", (notification_history_bundle) => {
state.notificationHistory = notification_history_bundle.history
state.notificationHistoryConfig = notification_history_bundle.config
});
socket.on("update_users_activity", (user_activity_bundle) => {
state.userActivity = user_activity_bundle.activity
state.userActivityConfig = user_activity_bundle.config
});
function addLimited(target, message, maxCount) { function addLimited(target, message, maxCount) {
target.unshift(message) target.unshift(message)
if (target.length > maxCount) { if (target.length > maxCount) {

View file

@ -8,13 +8,7 @@ export default {
extend: { extend: {
transitionProperty: { transitionProperty: {
'width': 'width' 'width': 'width'
}, } ,
screens: {
'3xl': '1800px',
},
fontSize: {
'xxs': '0.6rem',
},
}, },
}, },
plugins: [ plugins: [