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__
venv
config.py
misp_cache.sqlite
backup.json
# Logs
logs

View file

@ -8,15 +8,12 @@ source venv/bin/activate
# Install deps
pip3 install -r REQUIREMENTS
# Create config file and adapt it to your needs
cp config.py.sample config.py
```
## Running the PROD setup
```bash
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
python-socketio
aiohttp
eventlet
requests
requests-cache
jq

View file

@ -10,7 +10,6 @@ misp_skipssl = True
live_logs_accepted_scope = {
'events': ['add', 'edit', 'delete', 'restSearch',],
'attributes': ['add', 'edit', 'delete', 'restSearch',],
'eventReports': ['add', 'edit', 'delete',],
'tags': '*',
}
@ -19,6 +18,7 @@ logger = logging.getLogger('misp-exercise-dashboard')
format = '[%(levelname)s] %(asctime)s - %(message)s'
formatter = logging.Formatter(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.setLevel(logging.INFO)
ch.setFormatter(formatter)

38
db.py
View file

@ -5,49 +5,17 @@ import collections
USER_ID_TO_EMAIL_MAPPING = {}
USER_ID_TO_AUTHKEY_MAPPING = {}
ALL_EXERCISES = []
SELECTED_EXERCISES = []
INJECT_BY_UUID = {}
INJECT_SEQUENCE_BY_INJECT_UUID = {}
INJECT_REQUIREMENTS_BY_INJECT_UUID = {}
EXERCISES_STATUS = {}
PROGRESS = {
}
NOTIFICATION_BUFFER_SIZE = 30
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():
global NOTIFICATION_MESSAGES
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)
NOTIFICATION_MESSAGES = collections.deque([], NOTIFICATION_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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index-BbC5Fp4k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XAPeN3Gs.css">
<script type="module" crossorigin src="/assets/index-Bk6s3GdT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlglK08D.css">
</head>
<body>
<div id="app"></div>

View file

@ -2,6 +2,7 @@
import functools
import time
from collections import defaultdict
from pathlib import Path
import json
import re
@ -9,7 +10,7 @@ from typing import Union
import jq
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 config
from config import logger
@ -60,29 +61,6 @@ def read_exercise_dir():
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:
exercises_uuid = set()
tasks_uuid = set()
@ -162,14 +140,11 @@ def get_exercises():
tasks = []
for inject in exercise['injects']:
score = db.EXERCISES_STATUS[exercise['exercise']['uuid']]['tasks'][inject['uuid']]['score']
requirements = db.INJECT_REQUIREMENTS_BY_INJECT_UUID[inject['uuid']]
tasks.append(
{
"name": inject['name'],
"uuid": inject['uuid'],
"description": inject.get('description', ''),
"score": score,
"requirements": requirements,
}
)
exercises.append(
@ -204,7 +179,6 @@ def resetAllExerciseProgress():
for exercise_status in db.EXERCISES_STATUS.values():
for task in exercise_status['tasks'].values():
mark_task_incomplete(user_id, exercise_status['uuid'], task['uuid'])
backup_exercises_progress()
def 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 user_id in completion_per_user.keys():
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
for entry in task['completed_by_user']:
user_id = entry['user_id']
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
for user_id in task['completed_by_user']:
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = True
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):
is_completed = any(filter(lambda x: x['user_id'] == user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
if not is_completed:
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'].append({
'user_id': user_id,
'timestamp': time.time(),
'first_completion': False,
})
# Update who was the first to complete the task
first_completion_index = None
first_completion_time = time.time()
for i, entry in enumerate(db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']):
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][i]['first_completion'] = False
if entry['timestamp'] < first_completion_time:
first_completion_time = entry['timestamp']
first_completion_index = i
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][first_completion_index]['first_completion'] = True
if user_id not in 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)
def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
completed_without_user = list(filter(lambda x: x['user_id'] != user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'] = completed_without_user
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'].remove(user_id)
def get_progress():
@ -335,9 +294,9 @@ def get_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']:
success = await inject_checker_router(user_id, inject_evaluation, data, context)
success = inject_checker_router(user_id, inject_evaluation, data, context)
if not success:
logger.info(f"Task not completed: {inject['uuid']}")
return False
@ -361,39 +320,35 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
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):
return False
if 'evaluation_strategy' not in inject_evaluation:
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:
logger.debug('Could not fetch data to validate')
return False
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
return eval_data_filtering(user_id, inject_evaluation, data_to_validate, context)
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
return eval_data_filtering(user_id, inject_evaluation, data_to_validate)
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
expected_data = data_to_validate['expected_data']
data_to_validate = data_to_validate['data_to_validate']
return eval_query_mirror(user_id, expected_data, data_to_validate, context)
elif inject_evaluation['evaluation_strategy'] == 'query_search':
return eval_query_search(user_id, inject_evaluation, data_to_validate, context)
return eval_query_comparison(user_id, expected_data, data_to_validate)
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
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
event_id = parse_event_id_from_log(data)
data_to_validate = await fetch_data_for_data_filtering(event_id=event_id)
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
data_to_validate = fetch_data_for_data_filtering(event_id=event_id)
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
perfomed_query = parse_performed_query_from_log(data)
data_to_validate = await fetch_data_for_query_mirror(user_id, inject_evaluation, perfomed_query)
elif inject_evaluation['evaluation_strategy'] == 'query_search':
data_to_validate = await fetch_data_for_query_search(user_id, inject_evaluation)
data_to_validate = fetch_data_for_query_comparison(user_id, inject_evaluation, perfomed_query)
return data_to_validate
@ -439,14 +394,14 @@ def parse_performed_query_from_log(data: dict) -> Union[dict, 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
if event_id is not None:
data = await misp_api.getEvent(event_id)
data = misp_api.getEvent(event_id)
return data
async def fetch_data_for_query_mirror(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]:
def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfomed_query: dict) -> Union[None, dict]:
data = None
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
if perfomed_query is not None:
@ -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_url = query_context['url']
expected_payload = inject_evaluation['parameters'][0]
expected_data = await misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload)
data_to_validate = await misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload'])
expected_data = 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 = {
'expected_data' : expected_data,
'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
async def fetch_data_for_query_search(user_id: int, inject_evaluation: dict) -> Union[None, dict]:
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
if 'evaluation_context' not in inject_evaluation and 'query_context' not in inject_evaluation['evaluation_context']:
return None
query_context = inject_evaluation['evaluation_context']['query_context']
search_method = query_context['request_method']
search_url = query_context['url']
search_payload = query_context['payload']
search_data = await misp_api.doRestQuery(authkey, search_method, search_url, search_payload)
return search_data
@debounce_check_active_tasks(debounce_seconds=2)
async def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
succeeded_once = False
available_tasks = get_available_tasks_for_user(user_id)
for task_uuid in available_tasks:
@ -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:
continue
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:
succeeded_once = True
return succeeded_once

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import operator
from config import logger
# .Event.Attribute[] | select(.value == "evil.exe") | .Tag
def jq_extract(path: str, data: dict, extract_type='first'):
query = jq.compile(path).input_value(data)
try:
@ -14,42 +15,28 @@ def jq_extract(path: str, data: dict, extract_type='first'):
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
##
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:
data_to_validate = "1" if data_to_validate else "0"
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:
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:
# 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
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']
values = evaluation_config['values']
if len(values) == 0:
return False
values = [apply_replacement_from_context(v, context) for v in values]
if comparison_type == 'contains':
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
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']
values = evaluation_config['values']
comparators = {
@ -82,7 +69,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str, context:
if len(values) == 0:
return False
values = [apply_replacement_from_context(v, context) for v in values]
if comparison_type == 'contains' or comparison_type == 'equals':
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
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']
values = evaluation_config['values']
comparators = {
@ -126,10 +113,6 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str, context:
'=': operator.eq,
}
if len(values) == 0:
return False
values = [apply_replacement_from_context(v, context) for v in values]
comparison_type = evaluation_config['comparison']
if comparison_type == 'contains':
pass
@ -146,31 +129,21 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str, context:
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_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'))
if data_to_validate is None:
logger.debug('Could not extract data')
return False
if not condition_satisfied(evaluation_config, data_to_validate, context):
if not condition_satisfied(evaluation_config, data_to_validate):
return False
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
##
## 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 typing import Union
from urllib.parse import urljoin
import asyncio
import requests # type: ignore
import requests.adapters # type: ignore
from requests_cache import CachedSession
@ -19,7 +18,7 @@ requestSession.mount('https://', adapterCache)
requestSession.mount('http://', adapterCache)
async def get(url, data={}, api_key=misp_apikey):
def get(url, data={}, api_key=misp_apikey):
headers = {
'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key,
@ -28,22 +27,17 @@ async def get(url, data={}, api_key=misp_apikey):
}
full_url = urljoin(misp_url, url)
try:
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
response = requestSession.get(full_url, data=data, headers=headers, verify=not misp_skipssl)
except requests.exceptions.ConnectionError as e:
logger.info('Could not perform request on MISP. %s', e)
return None
except Exception as e:
logger.warning('Could not perform request on MISP. %s', e)
try:
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
except requests.exceptions.JSONDecodeError:
return response.text
async def post(url, data={}, api_key=misp_apikey):
def post(url, data={}, api_key=misp_apikey):
headers = {
'User-Agent': 'misp-exercise-dashboard',
"Authorization": api_key,
@ -52,37 +46,32 @@ async def post(url, data={}, api_key=misp_apikey):
}
full_url = urljoin(misp_url, url)
try:
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
response = requestSession.post(full_url, data=json.dumps(data), headers=headers, verify=not misp_skipssl)
except requests.exceptions.ConnectionError as e:
logger.info('Could not perform request on MISP. %s', e)
return None
except Exception as e:
logger.warning('Could not perform request on MISP. %s', e)
try:
return response.json() if response.headers['content-type'].startswith('application/json') else response.text
except requests.exceptions.JSONDecodeError:
return response.text
async def getEvent(event_id: int) -> Union[None, dict]:
return await get(f'/events/view/{event_id}')
def getEvent(event_id: int) -> Union[None, dict]:
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':
return await post(url, payload, api_key=authkey)
return post(url, payload, api_key=authkey)
else:
return await get(url, payload, api_key=authkey)
return get(url, payload, api_key=authkey)
async def getVersion() -> Union[None, dict]:
return await get(f'/servers/getVersion.json')
def getVersion() -> Union[None, dict]:
return get(f'/servers/getVersion.json')
async def getSettings() -> Union[None, dict]:
def getSettings() -> Union[None, dict]:
SETTING_TO_QUERY = [
'Plugin.ZeroMQ_enable',
'Plugin.ZeroMQ_audit_notifications_enable',
@ -94,7 +83,7 @@ async def getSettings() -> Union[None, dict]:
'MISP.log_auth',
'Security.allow_unsafe_cleartext_apikey_logging',
]
settings = await get(f'/servers/serverSettings.json')
settings = get(f'/servers/serverSettings.json')
if not settings:
return None
return {

View file

@ -9,7 +9,6 @@ from urllib.parse import parse_qs
VERBOSE_MODE = False
APIQUERY_MODE = False
NOTIFICATION_COUNT = 1
@ -18,39 +17,10 @@ def set_verbose_mode(enabled: bool):
VERBOSE_MODE = enabled
def set_apiquery_mode(enabled: bool):
global APIQUERY_MODE
APIQUERY_MODE = enabled
def get_notifications() -> list[dict]:
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():
db.resetNotificationMessage()
@ -59,14 +29,6 @@ def record_notification(notification: dict):
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):
if 'user_id' in data:
return int(data['user_id'])
@ -186,8 +148,6 @@ def is_accepted_notification(notification) -> bool:
return False
if VERBOSE_MODE:
return True
if APIQUERY_MODE and not notification['is_api_request']:
return False
if '@' not in notification['user']: # Ignore message from system
return False

126
package-lock.json generated
View file

@ -13,10 +13,8 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"apexcharts": "^3.49.2",
"lodash.debounce": "^4.0.8",
"vue": "^3.4.29",
"vue3-apexcharts": "^1.5.3"
"vue": "^3.4.29"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
@ -1079,12 +1077,6 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz",
"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": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
@ -1165,21 +1157,6 @@
"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": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -3315,97 +3292,6 @@
"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": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
@ -3678,16 +3564,6 @@
"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": {
"version": "2.0.2",
"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-solid-svg-icons": "^6.5.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"apexcharts": "^3.49.2",
"lodash.debounce": "^4.0.8",
"vue": "^3.4.29",
"vue3-apexcharts": "^1.5.3"
"vue": "^3.4.29"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",

169
server.py
View file

@ -1,14 +1,13 @@
#!/usr/bin/env python3
import collections
import functools
import json
import sys
import time
import zmq
import socketio
from aiohttp import web
import zmq.asyncio
import eventlet
from eventlet.green import zmq as gzmq
import exercise as exercise_model
import notification as notification_model
@ -18,10 +17,7 @@ from config import logger
import misp_api
ZMQ_MESSAGE_COUNT_LAST_TIMESPAN = 0
ZMQ_MESSAGE_COUNT = 0
ZMQ_LAST_TIME = None
USER_ACTIVITY = collections.defaultdict(int)
def debounce(debounce_seconds: int = 1):
@ -45,91 +41,77 @@ def debounce(debounce_seconds: int = 1):
# Initialize ZeroMQ context and subscriber socket
context = zmq.asyncio.Context()
zsocket = context.socket(zmq.SUB)
context = gzmq.Context()
zsocket = context.socket(gzmq.SUB)
zmq_url = config.zmq_url
zsocket.connect(zmq_url)
zsocket.setsockopt_string(zmq.SUBSCRIBE, '')
zsocket.setsockopt_string(gzmq.SUBSCRIBE, '')
# Initialize Socket.IO server
sio = socketio.AsyncServer(cors_allowed_origins='*', async_mode='aiohttp')
app = web.Application()
sio.attach(app)
async def index(request):
with open('dist/index.html') as f:
return web.Response(text=f.read(), content_type='text/html')
sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
app = socketio.WSGIApp(sio, static_files={
'/': {'content_type': 'text/html', 'filename': 'dist/index.html'},
'/assets': './dist/assets',
})
@sio.event
async def connect(sid, environ):
def connect(sid, environ):
logger.debug("Client connected: %s", sid)
@sio.event
async def disconnect(sid):
def disconnect(sid):
logger.debug("Client disconnected: %s", sid)
@sio.event
async def get_exercises(sid):
def get_exercises(sid):
return exercise_model.get_exercises()
@sio.event
async def get_selected_exercises(sid):
def get_selected_exercises(sid):
return exercise_model.get_selected_exercises()
@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'])
@sio.event
async def get_progress(sid):
def get_progress(sid):
return exercise_model.get_progress()
@sio.event
async def get_notifications(sid):
def get_notifications(sid):
return notification_model.get_notifications()
@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'])
@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'])
@sio.event
async def reset_all_exercise_progress(sid):
def reset_all_exercise_progress(sid):
return exercise_model.resetAllExerciseProgress()
@sio.event
async def reset_notifications(sid):
def reset_notifications(sid):
return notification_model.reset_notifications()
@sio.event
async def get_diagnostic(sid):
return await getDiagnostic()
def get_diagnostic(sid):
return getDiagnostic()
@sio.event
async def get_users_activity(sid):
return notification_model.get_users_activity()
@sio.event
async def toggle_verbose_mode(sid, payload):
def toggle_verbose_mode(sid, payload):
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('*')
async def any_event(event, sid, data={}):
def any_event(event, sid, data={}):
logger.info('>> Unhandled event %s', event)
async def handleMessage(topic, s, message):
global ZMQ_MESSAGE_COUNT_LAST_TIMESPAN
def handleMessage(topic, s, message):
data = json.loads(message)
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 not in db.USER_ID_TO_EMAIL_MAPPING:
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)
if user_id is not None:
@ -149,33 +131,24 @@ async def handleMessage(topic, s, message):
notification = notification_model.get_notification_message(data)
if notification_model.is_accepted_notification(notification):
notification_model.record_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)
sio.emit('notification', notification)
user_id = notification_model.get_user_id(data)
if user_id is not None:
if exercise_model.is_accepted_query(data):
context = get_context(topic, user_id, data)
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
context = get_context(data)
succeeded_once = exercise_model.check_active_tasks(user_id, data, context)
if succeeded_once:
await sendRefreshScore()
sendRefreshScore()
@debounce(debounce_seconds=1)
async def sendRefreshScore():
await sio.emit('refresh_score')
def sendRefreshScore():
sio.emit('refresh_score')
def get_context(topic: str, user_id: int, data: dict) -> dict:
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),
}
def get_context(data: dict) -> dict:
context = {}
if 'Log' in data:
if 'request_is_rest' in data['Log']:
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
async def getDiagnostic() -> dict:
def getDiagnostic() -> dict:
global ZMQ_MESSAGE_COUNT
diagnostic = {}
misp_version = await misp_api.getVersion()
misp_version = misp_api.getVersion()
if misp_version is None:
diagnostic['online'] = False
return diagnostic
diagnostic['version'] = misp_version
misp_settings = await misp_api.getSettings()
misp_settings = misp_api.getSettings()
diagnostic['settings'] = misp_settings
diagnostic['zmq_message_count'] = ZMQ_MESSAGE_COUNT
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
async def forward_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT, ZMQ_LAST_TIME
def forward_zmq_to_socketio():
global ZMQ_MESSAGE_COUNT
while True:
message = await zsocket.recv_string()
message = zsocket.recv_string()
topic, s, m = message.partition(" ")
await handleMessage(topic, s, m)
try:
ZMQ_MESSAGE_COUNT += 1
ZMQ_LAST_TIME = time.time()
# await handleMessage(topic, s, m)
handleMessage(topic, s, m)
except Exception as 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__":
exercises_loaded = exercise_model.load_exercises()
@ -273,6 +194,8 @@ if __name__ == "__main__":
logger.critical('Could not load exercises')
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 TheDahboard from './TheDahboard.vue'
import { socketConnected } from "@/socket";
import { darkModeEnabled } from "@/settings.js"
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>
<template>
<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="flex gap-2">
<TheThemeButton></TheThemeButton>
@ -39,12 +37,4 @@ body {
@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>

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 components;
@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>
import { ref, watch, computed } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } from "@/socket";
import { ref, watch } from "vue"
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faSignal, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
import TheLiveLogsActivityGraphVue from "./TheLiveLogsActivityGraph.vue";
const verbose = ref(false)
const api_query = ref(false)
watch(verbose, (newValue) => {
toggleVerboseMode(newValue == true)
})
watch(api_query, (newValue) => {
toggleApiQueryMode(newValue == true)
})
function getClassFromResponseCode(response_code) {
if (String(response_code).startsWith('2')) {
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="mr-1">
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
Players:
User online:
</span>
<span class="font-bold">{{ userCount }}</span>
</span>
@ -58,22 +52,13 @@
<span class="font-bold">{{ notificationAPICounter }}</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-warning [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/>
<label class="mr-1 flex items-center cursor-pointer">
<input type="checkbox" class="toggle toggle-success [--fallback-su:#22c55e] mr-1" :checked="verbose" @change="verbose = !verbose"/>
Verbose
</label>
</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>
<TheLiveLogsActivityGraphVue></TheLiveLogsActivityGraphVue>
<table class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full">
<thead>
<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 { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faTimes, faGraduationCap, faMedal, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
import LiveLogsUserActivityGraph from "./LiveLogsUserActivityGraph.vue"
import { faCheck, faTimes, faGraduationCap } from '@fortawesome/free-solid-svg-icons'
const collapsed_panels = ref([])
@ -64,11 +63,10 @@
<th
v-for="(task, task_index) in exercise.tasks"
:key="task.name"
class="border-b border-slate-100 dark:border-slate-700 p-3 align-top"
:title="task.description"
class="border-b border-slate-100 dark:border-slate-700 p-3"
>
<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>
</div>
</th>
@ -86,58 +84,29 @@
</tr>
<template v-else>
<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">
<span class="flex flex-col max-w-60">
<span :title="user_id" class="text-nowrap inline-block leading-5 truncate">
<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>
<td class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6">
<span :title="user_id">
<span class="text-lg font-bold font-mono">{{ progress.email.split('@')[0] }}</span>
<span class="text-xs font-mono">@{{ progress.email.split('@')[1] }}</span>
</span>
</td>
<td
v-for="(task, task_index) in exercise.tasks"
: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
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)"
>
<span class="flex flex-col">
<span class="text-nowrap">
<FontAwesomeIcon
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'}`"
/>
<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 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>
<FontAwesomeIcon
:icon="progress.exercises[exercise.uuid].tasks_completion[task.uuid] ? faCheck : 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'}`"
/>
<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>
</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 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>
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)
})
import { socketConnected } from "@/socket";
</script>
<template>
<span class="flex flex-col justify-center mt-1">
<span :class="{
'px-2 rounded-md inline-block w-48 leading-4': true,
'text-slate-900 dark:text-slate-400': socketConnected,
'text-slate-50 bg-red-600 px-2 py-1': !socketConnected,
}">
<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-slate-50">Disconnected</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 :class="{
'px-2 py-1 rounded-md inline-block w-48': true,
'text-slate-900 dark:text-slate-400': socketConnected,
'text-slate-50 bg-red-600': !socketConnected,
}">
<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-slate-50">Disconnected</span>
</span>
</template>

View file

@ -2,16 +2,14 @@
import { ref, watch } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
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) => {
darkModeOn.value = newValue
if (newValue) {
document.getElementsByTagName('body')[0].classList.add('dark')
} else {
document.getElementsByTagName('body')[0].classList.remove('dark')
document.getElementsByTagName('body')[0].classList.add('dark')
} else {
document.getElementsByTagName('body')[0].classList.remove('dark')
}
})
</script>

View file

@ -1,10 +1,6 @@
import './assets/main.css'
import VueApexCharts from "vue3-apexcharts";
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.use(VueApexCharts)
app.mount('#app')
createApp(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'
// "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 initial_state = {
notificationEvents: [],
notificationCounter: 0,
notificationAPICounter: 0,
notificationHistory: [],
notificationHistoryConfig: {},
userActivity: {},
userActivityConfig: {},
exercises: [],
selected_exercises: [],
progresses: {},
@ -22,8 +18,7 @@ const initial_state = {
const state = reactive({ ...initial_state });
const connectionState = reactive({
connected: false,
zmq_last_time: false,
connected: false
})
@ -44,12 +39,7 @@ export const notificationCounter = computed(() => state.notificationCounter)
export const notificationAPICounter = computed(() => state.notificationAPICounter)
export const userCount = computed(() => Object.keys(state.progresses).length)
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 zmqLastTime = computed(() => connectionState.zmq_last_time)
export function resetState() {
Object.assign(state, initial_state);
@ -60,7 +50,6 @@ export function fullReload() {
getSelectedExercises()
getNotifications()
getProgress()
getUsersActivity()
}
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
@ -92,10 +81,6 @@ export function toggleVerboseMode(enabled) {
sendToggleVerboseMode(enabled)
}
export function toggleApiQueryMode(enabled) {
sendToggleApiQueryMode(enabled)
}
export const debouncedGetProgress = debounce(getProgress, 200, {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() {
state.diagnostic = {}
socket.emit("get_diagnostic", (diagnostic) => {
@ -174,13 +151,6 @@ function sendToggleVerboseMode(enabled) {
socket.emit("toggle_verbose_mode", payload, () => {})
}
function sendToggleApiQueryMode(enabled) {
const payload = {
apiquery: enabled
}
socket.emit("toggle_apiquery_mode", payload, () => {})
}
/* Event listener */
socket.on("connect", () => {
@ -207,20 +177,6 @@ socket.on("refresh_score", (new_user) => {
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) {
target.unshift(message)
if (target.length > maxCount) {

View file

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