Compare commits

...

35 commits

Author SHA1 Message Date
Sami Mokaddem
202f7b7eb6 chg: [build] Rebuild assets 2024-07-09 13:22:06 +02:00
Sami Mokaddem
0c2fdf5591 chg: [front:live-logs_user-activity-graph] Further improved UI 2024-07-09 13:19:27 +02:00
Sami Mokaddem
4d445f3408 chg: [build] Build assets 2024-07-09 13:02:24 +02:00
Sami Mokaddem
3d928b6437 chg: [front:live-logs_user_activity_graph] Improved colors 2024-07-09 13:00:26 +02:00
Sami Mokaddem
58da718c5d new: [app:user_activity] Added user activity chart 2024-07-09 12:19:20 +02:00
Sami Mokaddem
bc78e2f2cb chg: [app] Build frontend files 2024-07-08 15:06:38 +02:00
Sami Mokaddem
f1a0ed3ab1 chg: [front:theme] Better support of theme choice 2024-07-08 14:59:52 +02:00
Sami Mokaddem
e1010793dc chg: [front:live-logs-activity-graph] Split graph into its own component 2024-07-08 13:47:57 +02:00
Sami Mokaddem
58d4af812d chg: [app:live-logs] Improved notification activity chart 2024-07-08 13:17:12 +02:00
Sami Mokaddem
33bc5ca0bb chg: [app:config] Added eventReports as accepted notification scope 2024-07-08 10:25:19 +02:00
Sami Mokaddem
1277dbb132 new: [app:backup] Added backup feature that saves exercise progress every 5sec 2024-07-08 10:21:59 +02:00
Sami Mokaddem
6178592d10 chg: [app:live-logs] Increased refresh frequency of activity bars 2024-07-08 10:06:50 +02:00
Sami Mokaddem
dc1f7b6376 chg: [front:scores] Improved heading of the table 2024-07-08 10:06:33 +02:00
Sami Mokaddem
abca50d615 new: [app:notification_history] Added notification history support 2024-07-04 19:46:05 +02:00
Sami Mokaddem
06f179378f fix: [backend:server] Exception won't stop the server 2024-07-04 15:49:51 +02:00
Sami Mokaddem
a07a0de89e chg: [app] Updated build files 2024-07-04 15:02:22 +02:00
Sami Mokaddem
84ab2665d7 chg: [app:config] Avoid overriding config file and git conflicts 2024-07-04 14:59:19 +02:00
Sami Mokaddem
99f9751e22 new: [app:evaluator_query-search] Added query-search evaluator and made full evaluation chain async 2024-07-04 14:49:26 +02:00
Sami Mokaddem
8774d70759 new: [front:socketConnection] Added last-keep alive message 2024-07-04 11:30:44 +02:00
Sami Mokaddem
b79152a6e5 chg: [front:socketConnectionState] Small UI improvement for disconnected socket 2024-07-04 11:23:35 +02:00
Sami Mokaddem
44d040c70b new: [app:eval_strategy] Added new evaluation strategy query-search 2024-07-04 11:21:10 +02:00
Sami Mokaddem
a21549f587 chg: [front:live-logs] Better support of light theme 2024-07-04 10:18:35 +02:00
Sami Mokaddem
8cca38f527 chg: [front] Improved CSS 2024-07-04 10:10:38 +02:00
Sami Mokaddem
0a89423d31 chg: [front:scores] Small UI tweak 2024-07-04 09:03:55 +02:00
Sami Mokaddem
f0d079ea32 chg: [app] Renamed query-comparison with query-mirror 2024-07-04 08:46:08 +02:00
Sami Mokaddem
9b0cb51643 chg: [app] Renamed project 2024-07-04 08:40:02 +02:00
Sami Mokaddem
34a1242ed9 new: [front:scores] Added task depedency icon 2024-07-04 08:32:28 +02:00
Sami Mokaddem
7231a55356 chg: [front:scores] Added description and medal 2024-07-03 15:44:58 +02:00
Sami Mokaddem
d4f148bb69 chg: [front:live-logs] Renamed users into players 2024-07-03 13:31:25 +02:00
Sami Mokaddem
e16fc0c7cd new: [app] Added keepalive messages 2024-07-03 13:30:12 +02:00
Sami Mokaddem
34869497a1 new: [app:live_logs] Added filters for API queries only 2024-07-03 12:43:20 +02:00
Sami Mokaddem
fb32b59abe chg: [app:scores] Added support of first_completion 2024-07-03 12:37:23 +02:00
Sami Mokaddem
6814294e77 chg: [app:scores] Added completion timestamp 2024-07-03 12:28:47 +02:00
Sami Mokaddem
96d5b6d89e chg: [backend] Make everything async relying on asyncio 2024-07-03 11:51:44 +02:00
Sami Mokaddem
f191af8573 chg: [backend] Usage of aiohttp in place of eventlet 2024-07-02 16:48:55 +02:00
33 changed files with 2391 additions and 1042 deletions

2
.gitignore vendored
View file

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

View file

@ -8,12 +8,15 @@ 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:3000 with your browser # Access the page http://localhost:4000 with your browser
``` ```

View file

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

View file

@ -10,6 +10,7 @@ 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': '*',
} }
@ -18,7 +19,6 @@ 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,17 +5,49 @@ 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)

1480
dist/assets/index-BbC5Fp4k.js 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

File diff suppressed because one or more lines are too long

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

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-Bk6s3GdT.js"></script> <script type="module" crossorigin src="/assets/index-BbC5Fp4k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlglK08D.css"> <link rel="stylesheet" crossorigin href="/assets/index-XAPeN3Gs.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -2,7 +2,6 @@
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
@ -10,7 +9,7 @@ from typing import Union
import jq import jq
import db import db
from inject_evaluator import eval_data_filtering, eval_query_comparison from inject_evaluator import eval_data_filtering, eval_query_mirror, eval_query_search
import misp_api import misp_api
import config import config
from config import logger from config import logger
@ -61,6 +60,29 @@ 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()
@ -140,11 +162,14 @@ 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(
@ -179,6 +204,7 @@ 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):
@ -247,8 +273,9 @@ 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 user_id in task['completed_by_user']: for entry in task['completed_by_user']:
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = True user_id = entry['user_id']
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
return completion_per_user return completion_per_user
@ -268,13 +295,27 @@ 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):
if user_id not in db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']: is_completed = any(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'].append(user_id) 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): def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
if user_id in db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']: 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'].remove(user_id) db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'] = completed_without_user
def get_progress(): def get_progress():
@ -294,9 +335,9 @@ def get_progress():
return progress return progress
def check_inject(user_id: int, inject: dict, data: dict, context: dict) -> bool: async 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 = inject_checker_router(user_id, inject_evaluation, data, context) success = await 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
@ -320,35 +361,39 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
return False return False
return False return False
def inject_checker_router(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool: 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): 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 = get_data_to_validate(user_id, inject_evaluation, data) data_to_validate = await 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) return eval_data_filtering(user_id, inject_evaluation, data_to_validate, context)
elif inject_evaluation['evaluation_strategy'] == 'query_comparison': elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
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_comparison(user_id, expected_data, 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 return False
def get_data_to_validate(user_id: int, inject_evaluation: dict, data: dict) -> Union[dict, list, str, None]: async 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 = fetch_data_for_data_filtering(event_id=event_id) data_to_validate = await fetch_data_for_data_filtering(event_id=event_id)
elif inject_evaluation['evaluation_strategy'] == 'query_comparison': elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
perfomed_query = parse_performed_query_from_log(data) perfomed_query = parse_performed_query_from_log(data)
data_to_validate = fetch_data_for_query_comparison(user_id, inject_evaluation, perfomed_query) 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 return data_to_validate
@ -394,14 +439,14 @@ def parse_performed_query_from_log(data: dict) -> Union[dict, None]:
return None return None
def fetch_data_for_data_filtering(event_id=None) -> Union[None, dict]: async 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 = misp_api.getEvent(event_id) data = await misp_api.getEvent(event_id)
return data return data
def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]: async def fetch_data_for_query_mirror(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:
@ -411,8 +456,8 @@ def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfo
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 = misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload) expected_data = await misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload)
data_to_validate = misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload']) data_to_validate = await 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,
@ -420,8 +465,20 @@ def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfo
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)
def check_active_tasks(user_id: int, data: dict, context: dict) -> bool: async 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:
@ -429,7 +486,7 @@ 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 = check_inject(user_id, inject, data, context) completed = await 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,7 +138,15 @@
{ {
"parameters": [ "parameters": [
{ {
".Event.info": { ".response[].Event.event_creator_email": {
"comparison": "equals",
"values": [
"{{user_email}}"
]
}
},
{
".response[].Event.info": {
"comparison": "contains", "comparison": "contains",
"values": [ "values": [
"event", "event",
@ -148,9 +156,17 @@
} }
], ],
"result": "MISP Event created", "result": "MISP Event created",
"evaluation_strategy": "data_filtering", "evaluation_strategy": "query_search",
"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_comparison", "evaluation_strategy": "query_mirror",
"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-query", "target_tool": "MISP",
"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_comparison", "evaluation_strategy": "query_mirror",
"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-query", "target_tool": "MISP",
"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_comparison", "evaluation_strategy": "query_mirror",
"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-query", "target_tool": "MISP",
"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_comparison", "evaluation_strategy": "query_mirror",
"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-query", "target_tool": "MISP",
"uuid": "1da0fdc8-9d0d-4618-a811-66491e196833" "uuid": "1da0fdc8-9d0d-4618-a811-66491e196833"
} }
] ]

View file

@ -219,6 +219,7 @@
} }
], ],
"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,7 +6,6 @@ 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:
@ -15,28 +14,42 @@ 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]) -> bool: def condition_satisfied(evaluation_config: dict, data_to_validate: Union[dict, list, str], context: dict) -> 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) return eval_condition_str(evaluation_config, data_to_validate, context)
elif type(data_to_validate) is list: elif type(data_to_validate) is list:
return eval_condition_list(evaluation_config, data_to_validate) return eval_condition_list(evaluation_config, data_to_validate, context)
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) return eval_condition_dict(evaluation_config, data_to_validate, context)
return False return False
def eval_condition_str(evaluation_config: dict, data_to_validate: str) -> bool: def eval_condition_str(evaluation_config: dict, data_to_validate: str, context: dict) -> 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]
@ -56,7 +69,7 @@ def eval_condition_str(evaluation_config: dict, data_to_validate: str) -> bool:
return False return False
def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool: def eval_condition_list(evaluation_config: dict, data_to_validate: str, context: dict) -> bool:
comparison_type = evaluation_config['comparison'] comparison_type = evaluation_config['comparison']
values = evaluation_config['values'] values = evaluation_config['values']
comparators = { comparators = {
@ -69,7 +82,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
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)
@ -102,7 +115,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
return False return False
def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool: def eval_condition_dict(evaluation_config: dict, data_to_validate: str, context: dict) -> bool:
comparison_type = evaluation_config['comparison'] comparison_type = evaluation_config['comparison']
values = evaluation_config['values'] values = evaluation_config['values']
comparators = { comparators = {
@ -113,6 +126,10 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
'=': 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
@ -129,21 +146,31 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
return False return False
def eval_data_filtering(user_id: int, inject_evaluation: dict, data: dict) -> bool: def eval_data_filtering(user_id: int, inject_evaluation: dict, data: dict, context: 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): if not condition_satisfied(evaluation_config, data_to_validate, context):
return False return False
return True return True
## ##
## Query comparison ## Query mirror
## ##
def eval_query_comparison(user_id: int, expected_data, data_to_validate) -> bool: def eval_query_mirror(user_id: int, expected_data, data_to_validate, context: dict) -> 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,6 +4,7 @@ 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
@ -18,7 +19,7 @@ requestSession.mount('https://', adapterCache)
requestSession.mount('http://', adapterCache) requestSession.mount('http://', adapterCache)
def get(url, data={}, api_key=misp_apikey): async 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,
@ -27,17 +28,22 @@ def get(url, data={}, api_key=misp_apikey):
} }
full_url = urljoin(misp_url, url) full_url = urljoin(misp_url, url)
try: try:
response = requestSession.get(full_url, data=data, headers=headers, verify=not misp_skipssl) loop = asyncio.get_event_loop()
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
def post(url, data={}, api_key=misp_apikey): async 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,
@ -46,32 +52,37 @@ def post(url, data={}, api_key=misp_apikey):
} }
full_url = urljoin(misp_url, url) full_url = urljoin(misp_url, url)
try: try:
response = requestSession.post(full_url, data=json.dumps(data), headers=headers, verify=not misp_skipssl) loop = asyncio.get_event_loop()
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
def getEvent(event_id: int) -> Union[None, dict]: async def getEvent(event_id: int) -> Union[None, dict]:
return get(f'/events/view/{event_id}') return await get(f'/events/view/{event_id}')
def doRestQuery(authkey: str, request_method: str, url: str, payload: dict = {}) -> Union[None, dict]: async def doRestQuery(authkey: str, request_method: str, url: str, payload: dict = {}) -> Union[None, dict]:
if request_method == 'POST': if request_method == 'POST':
return post(url, payload, api_key=authkey) return await post(url, payload, api_key=authkey)
else: else:
return get(url, payload, api_key=authkey) return await get(url, payload, api_key=authkey)
def getVersion() -> Union[None, dict]: async def getVersion() -> Union[None, dict]:
return get(f'/servers/getVersion.json') return await get(f'/servers/getVersion.json')
def getSettings() -> Union[None, dict]: async 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',
@ -83,7 +94,7 @@ def getSettings() -> Union[None, dict]:
'MISP.log_auth', 'MISP.log_auth',
'Security.allow_unsafe_cleartext_apikey_logging', 'Security.allow_unsafe_cleartext_apikey_logging',
] ]
settings = get(f'/servers/serverSettings.json') settings = await get(f'/servers/serverSettings.json')
if not settings: if not settings:
return None return None
return { return {

View file

@ -9,6 +9,7 @@ from urllib.parse import parse_qs
VERBOSE_MODE = False VERBOSE_MODE = False
APIQUERY_MODE = False
NOTIFICATION_COUNT = 1 NOTIFICATION_COUNT = 1
@ -17,10 +18,39 @@ 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()
@ -29,6 +59,14 @@ 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'])
@ -148,6 +186,8 @@ 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,8 +13,10 @@
"@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",
@ -1077,6 +1079,12 @@
"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",
@ -1157,6 +1165,21 @@
"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",
@ -3292,6 +3315,97 @@
"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",
@ -3564,6 +3678,16 @@
"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,8 +16,10 @@
"@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",

171
server.py
View file

@ -1,13 +1,14 @@
#!/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
import eventlet from aiohttp import web
from eventlet.green import zmq as gzmq import zmq.asyncio
import exercise as exercise_model import exercise as exercise_model
import notification as notification_model import notification as notification_model
@ -17,7 +18,10 @@ 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):
@ -41,77 +45,91 @@ def debounce(debounce_seconds: int = 1):
# Initialize ZeroMQ context and subscriber socket # Initialize ZeroMQ context and subscriber socket
context = gzmq.Context() context = zmq.asyncio.Context()
zsocket = context.socket(gzmq.SUB) zsocket = context.socket(zmq.SUB)
zmq_url = config.zmq_url zmq_url = config.zmq_url
zsocket.connect(zmq_url) zsocket.connect(zmq_url)
zsocket.setsockopt_string(gzmq.SUBSCRIBE, '') zsocket.setsockopt_string(zmq.SUBSCRIBE, '')
# Initialize Socket.IO server # Initialize Socket.IO server
sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet') sio = socketio.AsyncServer(cors_allowed_origins='*', async_mode='aiohttp')
app = socketio.WSGIApp(sio, static_files={ app = web.Application()
'/': {'content_type': 'text/html', 'filename': 'dist/index.html'}, sio.attach(app)
'/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
def connect(sid, environ): async def connect(sid, environ):
logger.debug("Client connected: %s", sid) logger.debug("Client connected: %s", sid)
@sio.event @sio.event
def disconnect(sid): async def disconnect(sid):
logger.debug("Client disconnected: %s", sid) logger.debug("Client disconnected: %s", sid)
@sio.event @sio.event
def get_exercises(sid): async def get_exercises(sid):
return exercise_model.get_exercises() return exercise_model.get_exercises()
@sio.event @sio.event
def get_selected_exercises(sid): async def get_selected_exercises(sid):
return exercise_model.get_selected_exercises() return exercise_model.get_selected_exercises()
@sio.event @sio.event
def change_exercise_selection(sid, payload): async 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
def get_progress(sid): async def get_progress(sid):
return exercise_model.get_progress() return exercise_model.get_progress()
@sio.event @sio.event
def get_notifications(sid): async def get_notifications(sid):
return notification_model.get_notifications() return notification_model.get_notifications()
@sio.event @sio.event
def mark_task_completed(sid, payload): async 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
def mark_task_incomplete(sid, payload): async 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
def reset_all_exercise_progress(sid): async def reset_all_exercise_progress(sid):
return exercise_model.resetAllExerciseProgress() return exercise_model.resetAllExerciseProgress()
@sio.event @sio.event
def reset_notifications(sid): async def reset_notifications(sid):
return notification_model.reset_notifications() return notification_model.reset_notifications()
@sio.event @sio.event
def get_diagnostic(sid): async def get_diagnostic(sid):
return getDiagnostic() return await getDiagnostic()
@sio.event @sio.event
def toggle_verbose_mode(sid, payload): async def get_users_activity(sid):
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('*')
def any_event(event, sid, data={}): async def any_event(event, sid, data={}):
logger.info('>> Unhandled event %s', event) logger.info('>> Unhandled event %s', event)
def handleMessage(topic, s, message): async 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':
@ -119,7 +137,7 @@ 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
sio.emit('new_user', email) await 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:
@ -131,24 +149,33 @@ 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)
sio.emit('notification', notification) ZMQ_MESSAGE_COUNT_LAST_TIMESPAN += 1
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(data) context = get_context(topic, user_id, data)
succeeded_once = exercise_model.check_active_tasks(user_id, data, context) succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
if succeeded_once: if succeeded_once:
sendRefreshScore() await sendRefreshScore()
@debounce(debounce_seconds=1) @debounce(debounce_seconds=1)
def sendRefreshScore(): async def sendRefreshScore():
sio.emit('refresh_score') await sio.emit('refresh_score')
def get_context(data: dict) -> dict: def get_context(topic: str, user_id: int, 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']
@ -158,35 +185,87 @@ def get_context(data: dict) -> dict:
return context return context
def getDiagnostic() -> dict: async def getDiagnostic() -> dict:
global ZMQ_MESSAGE_COUNT global ZMQ_MESSAGE_COUNT
diagnostic = {} diagnostic = {}
misp_version = misp_api.getVersion() misp_version = await 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 = misp_api.getSettings() misp_settings = await 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
# Function to forward zmq messages to Socket.IO async def notification_history():
def forward_zmq_to_socketio(): global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
global ZMQ_MESSAGE_COUNT 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: while True:
message = zsocket.recv_string() 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
async def forward_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT, ZMQ_LAST_TIME
while True:
message = await 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
handleMessage(topic, s, m) ZMQ_LAST_TIME = time.time()
# 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()
@ -194,8 +273,6 @@ if __name__ == "__main__":
logger.critical('Could not load exercises') logger.critical('Could not load exercises')
sys.exit(1) sys.exit(1)
# Start the forwarding in a separate thread exercise_model.restore_exercices_progress()
eventlet.spawn_n(forward_zmq_to_socketio)
# Run the Socket.IO server web.run_app(init_app(), host=config.server_host, port=config.server_port)
eventlet.wsgi.server(eventlet.listen((config.server_host, config.server_port)), app)

View file

@ -5,18 +5,20 @@ 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">MISP Exercise Dashboard</h1> <h1 class="text-2xl text-center text-slate-500 dark:text-slate-400 absolute top-1 left-1">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>
@ -37,4 +39,12 @@ 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>

View file

@ -1,86 +0,0 @@
/* 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,20 +1,3 @@
@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

@ -0,0 +1,129 @@
<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,16 +1,22 @@
<script setup> <script setup>
import { ref, watch } from "vue" import { ref, watch, computed } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode } from "@/socket"; import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } 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'
@ -33,7 +39,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>
User online: Players:
</span> </span>
<span class="font-bold">{{ userCount }}</span> <span class="font-bold">{{ userCount }}</span>
</span> </span>
@ -52,13 +58,22 @@
<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"> <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="verbose" @change="verbose = !verbose"/> <input type="checkbox" class="toggle toggle-warning [--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

@ -0,0 +1,83 @@
<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,7 +2,8 @@
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 } from '@fortawesome/free-solid-svg-icons' import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
const collapsed_panels = ref([]) const collapsed_panels = ref([])
@ -63,10 +64,11 @@
<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" class="border-b border-slate-100 dark:border-slate-700 p-3 align-top"
: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">Task {{ task_index + 1 }}</span> <span class="text-center font-normal text-sm dark:text-blue-200 text-slate-500 text-nowrap">Task {{ task_index + 1 }}</span>
<i class="text-center">{{ task.name }}</i> <i class="text-center">{{ task.name }}</i>
</div> </div>
</th> </th>
@ -84,29 +86,58 @@
</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-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"> <td class="border-b border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-0 pl-2 relative">
<span :title="user_id"> <span class="flex flex-col max-w-60">
<span class="text-lg font-bold font-mono">{{ progress.email.split('@')[0] }}</span> <span :title="user_id" class="text-nowrap inline-block leading-5 truncate">
<span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</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-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-100 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3" class="text-center border-b border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-2"
> >
<span <span
class="select-none cursor-pointer text-nowrap" class="select-none cursor-pointer flex justify-center content-center flex-wrap h-9"
@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
:icon="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? faCheck : faTimes" v-if="progress.exercises[exercise.uuid].tasks_completion[task.uuid]"
: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-100 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3"> <td class="border-b border-slate-200 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,16 +1,46 @@
<script setup> <script setup>
import { socketConnected } from "@/socket"; import { ref, onMounted } from "vue"
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 py-1 rounded-md inline-block w-48': true, 'px-2 rounded-md inline-block w-48 leading-4': true,
'text-slate-900 dark:text-slate-400': socketConnected, 'text-slate-900 dark:text-slate-400': socketConnected,
'text-slate-50 bg-red-600': !socketConnected, 'text-slate-50 bg-red-600 px-2 py-1': !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,10 +2,12 @@
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(true) const darkMode = ref(darkModeOn.value)
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,6 +1,10 @@
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'
createApp(App).mount('#app') const app = createApp(App)
app.use(VueApexCharts)
app.mount('#app')

4
src/settings.js Normal file
View file

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

View file

@ -3,13 +3,17 @@ 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:4000"; const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:40001";
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: {},
@ -18,7 +22,8 @@ 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,
}) })
@ -39,7 +44,12 @@ 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);
@ -50,6 +60,7 @@ 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) {
@ -81,6 +92,10 @@ 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})
@ -112,6 +127,14 @@ 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) => {
@ -151,6 +174,13 @@ 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", () => {
@ -177,6 +207,20 @@ 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

@ -9,6 +9,12 @@ export default {
transitionProperty: { transitionProperty: {
'width': 'width' 'width': 'width'
}, },
screens: {
'3xl': '1800px',
},
fontSize: {
'xxs': '0.6rem',
},
}, },
}, },
plugins: [ plugins: [