Compare commits
35 commits
f236da0055
...
202f7b7eb6
Author | SHA1 | Date | |
---|---|---|---|
|
202f7b7eb6 | ||
|
0c2fdf5591 | ||
|
4d445f3408 | ||
|
3d928b6437 | ||
|
58da718c5d | ||
|
bc78e2f2cb | ||
|
f1a0ed3ab1 | ||
|
e1010793dc | ||
|
58d4af812d | ||
|
33bc5ca0bb | ||
|
1277dbb132 | ||
|
6178592d10 | ||
|
dc1f7b6376 | ||
|
abca50d615 | ||
|
06f179378f | ||
|
a07a0de89e | ||
|
84ab2665d7 | ||
|
99f9751e22 | ||
|
8774d70759 | ||
|
b79152a6e5 | ||
|
44d040c70b | ||
|
a21549f587 | ||
|
8cca38f527 | ||
|
0a89423d31 | ||
|
f0d079ea32 | ||
|
9b0cb51643 | ||
|
34a1242ed9 | ||
|
7231a55356 | ||
|
d4f148bb69 | ||
|
e16fc0c7cd | ||
|
34869497a1 | ||
|
fb32b59abe | ||
|
6814294e77 | ||
|
96d5b6d89e | ||
|
f191af8573 |
33 changed files with 2391 additions and 1042 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,6 +1,8 @@
|
|||
__pycache__
|
||||
venv
|
||||
config.py
|
||||
misp_cache.sqlite
|
||||
backup.json
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
@ -8,12 +8,15 @@ 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:3000 with your browser
|
||||
# Access the page http://localhost:4000 with your browser
|
||||
```
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
pyzmq
|
||||
python-socketio
|
||||
eventlet
|
||||
aiohttp
|
||||
requests
|
||||
requests-cache
|
||||
jq
|
|
@ -10,6 +10,7 @@ misp_skipssl = True
|
|||
live_logs_accepted_scope = {
|
||||
'events': ['add', 'edit', 'delete', 'restSearch',],
|
||||
'attributes': ['add', 'edit', 'delete', 'restSearch',],
|
||||
'eventReports': ['add', 'edit', 'delete',],
|
||||
'tags': '*',
|
||||
}
|
||||
|
||||
|
@ -18,7 +19,6 @@ 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)
|
36
db.py
36
db.py
|
@ -5,17 +5,49 @@ 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)
|
1480
dist/assets/index-BbC5Fp4k.js
vendored
Normal file
1480
dist/assets/index-BbC5Fp4k.js
vendored
Normal file
File diff suppressed because one or more lines are too long
778
dist/assets/index-Bk6s3GdT.js
vendored
778
dist/assets/index-Bk6s3GdT.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-DlglK08D.css
vendored
1
dist/assets/index-DlglK08D.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-XAPeN3Gs.css
vendored
Normal file
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
4
dist/index.html
vendored
|
@ -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-Bk6s3GdT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DlglK08D.css">
|
||||
<script type="module" crossorigin src="/assets/index-BbC5Fp4k.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-XAPeN3Gs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
109
exercise.py
109
exercise.py
|
@ -2,7 +2,6 @@
|
|||
|
||||
import functools
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
|
@ -10,7 +9,7 @@ from typing import Union
|
|||
import jq
|
||||
|
||||
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 config
|
||||
from config import logger
|
||||
|
@ -61,6 +60,29 @@ 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()
|
||||
|
@ -140,11 +162,14 @@ 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(
|
||||
|
@ -179,6 +204,7 @@ 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):
|
||||
|
@ -247,8 +273,9 @@ 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 user_id in task['completed_by_user']:
|
||||
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = True
|
||||
for entry in task['completed_by_user']:
|
||||
user_id = entry['user_id']
|
||||
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = entry
|
||||
|
||||
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):
|
||||
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)
|
||||
is_completed = any(filter(lambda x: x['user_id'] == user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
|
||||
if not is_completed:
|
||||
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'].append({
|
||||
'user_id': user_id,
|
||||
'timestamp': time.time(),
|
||||
'first_completion': False,
|
||||
})
|
||||
# Update who was the first to complete the task
|
||||
first_completion_index = None
|
||||
first_completion_time = time.time()
|
||||
for i, entry in enumerate(db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']):
|
||||
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][i]['first_completion'] = False
|
||||
if entry['timestamp'] < first_completion_time:
|
||||
first_completion_time = entry['timestamp']
|
||||
first_completion_index = i
|
||||
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'][first_completion_index]['first_completion'] = True
|
||||
|
||||
|
||||
def mark_task_incomplete(user_id: int, exercise_uuid: str , task_uuid: str):
|
||||
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)
|
||||
completed_without_user = list(filter(lambda x: x['user_id'] != user_id, db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user']))
|
||||
db.EXERCISES_STATUS[exercise_uuid]['tasks'][task_uuid]['completed_by_user'] = completed_without_user
|
||||
|
||||
|
||||
def get_progress():
|
||||
|
@ -294,9 +335,9 @@ def get_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']:
|
||||
success = inject_checker_router(user_id, inject_evaluation, data, context)
|
||||
success = await inject_checker_router(user_id, inject_evaluation, data, context)
|
||||
if not success:
|
||||
logger.info(f"Task not completed: {inject['uuid']}")
|
||||
return False
|
||||
|
@ -320,35 +361,39 @@ def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dic
|
|||
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):
|
||||
return False
|
||||
|
||||
if 'evaluation_strategy' not in inject_evaluation:
|
||||
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:
|
||||
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)
|
||||
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
|
||||
return eval_data_filtering(user_id, inject_evaluation, data_to_validate, context)
|
||||
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
|
||||
expected_data = data_to_validate['expected_data']
|
||||
data_to_validate = data_to_validate['data_to_validate']
|
||||
return eval_query_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
|
||||
|
||||
|
||||
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
|
||||
if inject_evaluation['evaluation_strategy'] == 'data_filtering':
|
||||
event_id = parse_event_id_from_log(data)
|
||||
data_to_validate = fetch_data_for_data_filtering(event_id=event_id)
|
||||
elif inject_evaluation['evaluation_strategy'] == 'query_comparison':
|
||||
data_to_validate = await fetch_data_for_data_filtering(event_id=event_id)
|
||||
elif inject_evaluation['evaluation_strategy'] == 'query_mirror':
|
||||
perfomed_query = parse_performed_query_from_log(data)
|
||||
data_to_validate = 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
|
||||
|
||||
|
||||
|
@ -394,14 +439,14 @@ def parse_performed_query_from_log(data: dict) -> Union[dict, 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
|
||||
if event_id is not None:
|
||||
data = misp_api.getEvent(event_id)
|
||||
data = await misp_api.getEvent(event_id)
|
||||
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
|
||||
authkey = db.USER_ID_TO_AUTHKEY_MAPPING[user_id]
|
||||
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_url = query_context['url']
|
||||
expected_payload = inject_evaluation['parameters'][0]
|
||||
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'])
|
||||
expected_data = await misp_api.doRestQuery(authkey, expected_method, expected_url, expected_payload)
|
||||
data_to_validate = await misp_api.doRestQuery(authkey, perfomed_query['request_method'], perfomed_query['url'], perfomed_query['payload'])
|
||||
data = {
|
||||
'expected_data' : expected_data,
|
||||
'data_to_validate' : data_to_validate,
|
||||
|
@ -420,8 +465,20 @@ def fetch_data_for_query_comparison(user_id: int, inject_evaluation: dict, perfo
|
|||
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)
|
||||
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
|
||||
available_tasks = get_available_tasks_for_user(user_id)
|
||||
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:
|
||||
continue
|
||||
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:
|
||||
succeeded_once = True
|
||||
return succeeded_once
|
|
@ -138,7 +138,15 @@
|
|||
{
|
||||
"parameters": [
|
||||
{
|
||||
".Event.info": {
|
||||
".response[].Event.event_creator_email": {
|
||||
"comparison": "equals",
|
||||
"values": [
|
||||
"{{user_email}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
".response[].Event.info": {
|
||||
"comparison": "contains",
|
||||
"values": [
|
||||
"event",
|
||||
|
@ -148,9 +156,17 @@
|
|||
}
|
||||
],
|
||||
"result": "MISP Event created",
|
||||
"evaluation_strategy": "data_filtering",
|
||||
"evaluation_strategy": "query_search",
|
||||
"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": [
|
||||
0,
|
||||
|
|
|
@ -119,7 +119,7 @@
|
|||
}
|
||||
],
|
||||
"result": "Published 48h retreived",
|
||||
"evaluation_strategy": "query_comparison",
|
||||
"evaluation_strategy": "query_mirror",
|
||||
"evaluation_context": {
|
||||
"request_is_rest": true,
|
||||
"query_context": {
|
||||
|
@ -134,7 +134,7 @@
|
|||
}
|
||||
],
|
||||
"name": "Get Published in the past 48h",
|
||||
"target_tool": "MISP-query",
|
||||
"target_tool": "MISP",
|
||||
"uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4"
|
||||
},
|
||||
{
|
||||
|
@ -150,7 +150,7 @@
|
|||
}
|
||||
],
|
||||
"result": "IP CSV retrieved",
|
||||
"evaluation_strategy": "query_comparison",
|
||||
"evaluation_strategy": "query_mirror",
|
||||
"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-query",
|
||||
"target_tool": "MISP",
|
||||
"uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba"
|
||||
},
|
||||
{
|
||||
|
@ -180,7 +180,7 @@
|
|||
}
|
||||
],
|
||||
"result": "20 Attribute tagged retrieved",
|
||||
"evaluation_strategy": "query_comparison",
|
||||
"evaluation_strategy": "query_mirror",
|
||||
"evaluation_context": {
|
||||
"request_is_rest": true,
|
||||
"query_context": {
|
||||
|
@ -195,7 +195,7 @@
|
|||
}
|
||||
],
|
||||
"name": "First 20 Attribute with TLP lower than `amber`",
|
||||
"target_tool": "MISP-query",
|
||||
"target_tool": "MISP",
|
||||
"uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88"
|
||||
},
|
||||
{
|
||||
|
@ -209,7 +209,7 @@
|
|||
}
|
||||
],
|
||||
"result": "Phising counted",
|
||||
"evaluation_strategy": "query_comparison",
|
||||
"evaluation_strategy": "query_mirror",
|
||||
"evaluation_context": {
|
||||
"request_is_rest": true,
|
||||
"query_context": {
|
||||
|
@ -224,7 +224,7 @@
|
|||
}
|
||||
],
|
||||
"name": "Event count with `Phishing - T1566` involved",
|
||||
"target_tool": "MISP-query",
|
||||
"target_tool": "MISP",
|
||||
"uuid": "1da0fdc8-9d0d-4618-a811-66491e196833"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -219,6 +219,7 @@
|
|||
}
|
||||
],
|
||||
"name": "Event Creation",
|
||||
"description": "Create an Event containing `ransomware`",
|
||||
"target_tool": "MISP",
|
||||
"uuid": "8e8dbda2-0f5e-4101-83ff-63c1ddda2cae"
|
||||
},
|
||||
|
|
|
@ -6,7 +6,6 @@ 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:
|
||||
|
@ -15,28 +14,42 @@ 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]) -> bool:
|
||||
def condition_satisfied(evaluation_config: dict, data_to_validate: Union[dict, list, str], context: dict) -> 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)
|
||||
return eval_condition_str(evaluation_config, data_to_validate, context)
|
||||
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:
|
||||
# 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
|
||||
|
||||
|
||||
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']
|
||||
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]
|
||||
|
@ -56,7 +69,7 @@ def eval_condition_str(evaluation_config: dict, data_to_validate: str) -> bool:
|
|||
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']
|
||||
values = evaluation_config['values']
|
||||
comparators = {
|
||||
|
@ -69,7 +82,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
|
|||
|
||||
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)
|
||||
|
@ -102,7 +115,7 @@ def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
|
|||
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']
|
||||
values = evaluation_config['values']
|
||||
comparators = {
|
||||
|
@ -113,6 +126,10 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
|
|||
'=': 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
|
||||
|
@ -129,21 +146,31 @@ def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
|
|||
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_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):
|
||||
if not condition_satisfied(evaluation_config, data_to_validate, context):
|
||||
return False
|
||||
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
|
||||
|
||||
|
||||
|
||||
##
|
||||
## 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)
|
37
misp_api.py
37
misp_api.py
|
@ -4,6 +4,7 @@ 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
|
||||
|
@ -18,7 +19,7 @@ requestSession.mount('https://', adapterCache)
|
|||
requestSession.mount('http://', adapterCache)
|
||||
|
||||
|
||||
def get(url, data={}, api_key=misp_apikey):
|
||||
async def get(url, data={}, api_key=misp_apikey):
|
||||
headers = {
|
||||
'User-Agent': 'misp-exercise-dashboard',
|
||||
"Authorization": api_key,
|
||||
|
@ -27,17 +28,22 @@ def get(url, data={}, api_key=misp_apikey):
|
|||
}
|
||||
full_url = urljoin(misp_url, url)
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
def post(url, data={}, api_key=misp_apikey):
|
||||
async def post(url, data={}, api_key=misp_apikey):
|
||||
headers = {
|
||||
'User-Agent': 'misp-exercise-dashboard',
|
||||
"Authorization": api_key,
|
||||
|
@ -46,32 +52,37 @@ def post(url, data={}, api_key=misp_apikey):
|
|||
}
|
||||
full_url = urljoin(misp_url, url)
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
def getEvent(event_id: int) -> Union[None, dict]:
|
||||
return get(f'/events/view/{event_id}')
|
||||
async def getEvent(event_id: int) -> Union[None, dict]:
|
||||
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':
|
||||
return post(url, payload, api_key=authkey)
|
||||
return await post(url, payload, api_key=authkey)
|
||||
else:
|
||||
return get(url, payload, api_key=authkey)
|
||||
return await get(url, payload, api_key=authkey)
|
||||
|
||||
|
||||
def getVersion() -> Union[None, dict]:
|
||||
return get(f'/servers/getVersion.json')
|
||||
async def getVersion() -> Union[None, dict]:
|
||||
return await get(f'/servers/getVersion.json')
|
||||
|
||||
|
||||
def getSettings() -> Union[None, dict]:
|
||||
async def getSettings() -> Union[None, dict]:
|
||||
SETTING_TO_QUERY = [
|
||||
'Plugin.ZeroMQ_enable',
|
||||
'Plugin.ZeroMQ_audit_notifications_enable',
|
||||
|
@ -83,7 +94,7 @@ def getSettings() -> Union[None, dict]:
|
|||
'MISP.log_auth',
|
||||
'Security.allow_unsafe_cleartext_apikey_logging',
|
||||
]
|
||||
settings = get(f'/servers/serverSettings.json')
|
||||
settings = await get(f'/servers/serverSettings.json')
|
||||
if not settings:
|
||||
return None
|
||||
return {
|
||||
|
|
|
@ -9,6 +9,7 @@ from urllib.parse import parse_qs
|
|||
|
||||
|
||||
VERBOSE_MODE = False
|
||||
APIQUERY_MODE = False
|
||||
NOTIFICATION_COUNT = 1
|
||||
|
||||
|
||||
|
@ -17,10 +18,39 @@ 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()
|
||||
|
||||
|
@ -29,6 +59,14 @@ 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'])
|
||||
|
@ -148,6 +186,8 @@ 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
126
package-lock.json
generated
|
@ -13,8 +13,10 @@
|
|||
"@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"
|
||||
"vue": "^3.4.29",
|
||||
"vue3-apexcharts": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
|
@ -1077,6 +1079,12 @@
|
|||
"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",
|
||||
|
@ -1157,6 +1165,21 @@
|
|||
"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",
|
||||
|
@ -3292,6 +3315,97 @@
|
|||
"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",
|
||||
|
@ -3564,6 +3678,16 @@
|
|||
"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",
|
||||
|
|
|
@ -16,8 +16,10 @@
|
|||
"@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"
|
||||
"vue": "^3.4.29",
|
||||
"vue3-apexcharts": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
|
|
171
server.py
171
server.py
|
@ -1,13 +1,14 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import collections
|
||||
import functools
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import zmq
|
||||
import socketio
|
||||
import eventlet
|
||||
from eventlet.green import zmq as gzmq
|
||||
from aiohttp import web
|
||||
import zmq.asyncio
|
||||
|
||||
import exercise as exercise_model
|
||||
import notification as notification_model
|
||||
|
@ -17,7 +18,10 @@ 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):
|
||||
|
@ -41,77 +45,91 @@ def debounce(debounce_seconds: int = 1):
|
|||
|
||||
|
||||
# Initialize ZeroMQ context and subscriber socket
|
||||
context = gzmq.Context()
|
||||
zsocket = context.socket(gzmq.SUB)
|
||||
context = zmq.asyncio.Context()
|
||||
zsocket = context.socket(zmq.SUB)
|
||||
zmq_url = config.zmq_url
|
||||
zsocket.connect(zmq_url)
|
||||
zsocket.setsockopt_string(gzmq.SUBSCRIBE, '')
|
||||
zsocket.setsockopt_string(zmq.SUBSCRIBE, '')
|
||||
|
||||
|
||||
# Initialize Socket.IO server
|
||||
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 = 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.event
|
||||
def connect(sid, environ):
|
||||
async def connect(sid, environ):
|
||||
logger.debug("Client connected: %s", sid)
|
||||
|
||||
@sio.event
|
||||
def disconnect(sid):
|
||||
async def disconnect(sid):
|
||||
logger.debug("Client disconnected: %s", sid)
|
||||
|
||||
@sio.event
|
||||
def get_exercises(sid):
|
||||
async def get_exercises(sid):
|
||||
return exercise_model.get_exercises()
|
||||
|
||||
@sio.event
|
||||
def get_selected_exercises(sid):
|
||||
async def get_selected_exercises(sid):
|
||||
return exercise_model.get_selected_exercises()
|
||||
|
||||
@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'])
|
||||
|
||||
@sio.event
|
||||
def get_progress(sid):
|
||||
async def get_progress(sid):
|
||||
return exercise_model.get_progress()
|
||||
|
||||
@sio.event
|
||||
def get_notifications(sid):
|
||||
async def get_notifications(sid):
|
||||
return notification_model.get_notifications()
|
||||
|
||||
@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'])
|
||||
|
||||
@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'])
|
||||
|
||||
@sio.event
|
||||
def reset_all_exercise_progress(sid):
|
||||
async def reset_all_exercise_progress(sid):
|
||||
return exercise_model.resetAllExerciseProgress()
|
||||
|
||||
@sio.event
|
||||
def reset_notifications(sid):
|
||||
async def reset_notifications(sid):
|
||||
return notification_model.reset_notifications()
|
||||
|
||||
@sio.event
|
||||
def get_diagnostic(sid):
|
||||
return getDiagnostic()
|
||||
async def get_diagnostic(sid):
|
||||
return await getDiagnostic()
|
||||
|
||||
@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'])
|
||||
|
||||
@sio.event
|
||||
async def toggle_apiquery_mode(sid, payload):
|
||||
return notification_model.set_apiquery_mode(payload['apiquery'])
|
||||
|
||||
@sio.on('*')
|
||||
def any_event(event, sid, data={}):
|
||||
async def any_event(event, sid, data={}):
|
||||
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)
|
||||
|
||||
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 not in db.USER_ID_TO_EMAIL_MAPPING:
|
||||
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)
|
||||
if user_id is not None:
|
||||
|
@ -131,24 +149,33 @@ def handleMessage(topic, s, message):
|
|||
notification = notification_model.get_notification_message(data)
|
||||
if notification_model.is_accepted_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)
|
||||
if user_id is not None:
|
||||
if exercise_model.is_accepted_query(data):
|
||||
context = get_context(data)
|
||||
succeeded_once = exercise_model.check_active_tasks(user_id, data, context)
|
||||
context = get_context(topic, user_id, data)
|
||||
succeeded_once = await exercise_model.check_active_tasks(user_id, data, context)
|
||||
if succeeded_once:
|
||||
sendRefreshScore()
|
||||
await sendRefreshScore()
|
||||
|
||||
|
||||
@debounce(debounce_seconds=1)
|
||||
def sendRefreshScore():
|
||||
sio.emit('refresh_score')
|
||||
async def sendRefreshScore():
|
||||
await sio.emit('refresh_score')
|
||||
|
||||
|
||||
def get_context(data: dict) -> dict:
|
||||
context = {}
|
||||
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),
|
||||
}
|
||||
if 'Log' in data:
|
||||
if 'request_is_rest' in data['Log']:
|
||||
context['request_is_rest'] = data['Log']['request_is_rest']
|
||||
|
@ -158,35 +185,87 @@ def get_context(data: dict) -> dict:
|
|||
return context
|
||||
|
||||
|
||||
def getDiagnostic() -> dict:
|
||||
async def getDiagnostic() -> dict:
|
||||
global ZMQ_MESSAGE_COUNT
|
||||
|
||||
diagnostic = {}
|
||||
misp_version = misp_api.getVersion()
|
||||
misp_version = await misp_api.getVersion()
|
||||
if misp_version is None:
|
||||
diagnostic['online'] = False
|
||||
return diagnostic
|
||||
diagnostic['version'] = misp_version
|
||||
misp_settings = misp_api.getSettings()
|
||||
misp_settings = await misp_api.getSettings()
|
||||
diagnostic['settings'] = misp_settings
|
||||
diagnostic['zmq_message_count'] = ZMQ_MESSAGE_COUNT
|
||||
return diagnostic
|
||||
|
||||
|
||||
# Function to forward zmq messages to Socket.IO
|
||||
def forward_zmq_to_socketio():
|
||||
global ZMQ_MESSAGE_COUNT
|
||||
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:
|
||||
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(" ")
|
||||
await handleMessage(topic, s, m)
|
||||
try:
|
||||
ZMQ_MESSAGE_COUNT += 1
|
||||
handleMessage(topic, s, m)
|
||||
ZMQ_LAST_TIME = time.time()
|
||||
# await 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()
|
||||
|
@ -194,8 +273,6 @@ if __name__ == "__main__":
|
|||
logger.critical('Could not load exercises')
|
||||
sys.exit(1)
|
||||
|
||||
# Start the forwarding in a separate thread
|
||||
eventlet.spawn_n(forward_zmq_to_socketio)
|
||||
exercise_model.restore_exercices_progress()
|
||||
|
||||
# Run the Socket.IO server
|
||||
eventlet.wsgi.server(eventlet.listen((config.server_host, config.server_port)), app)
|
||||
web.run_app(init_app(), host=config.server_host, port=config.server_port)
|
||||
|
|
14
src/App.vue
14
src/App.vue
|
@ -5,18 +5,20 @@ 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.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">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="flex gap-2">
|
||||
<TheThemeButton></TheThemeButton>
|
||||
|
@ -37,4 +39,12 @@ 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>
|
|
@ -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;
|
||||
}
|
|
@ -1,20 +1,3 @@
|
|||
@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;
|
||||
}
|
||||
} */
|
||||
|
|
129
src/components/LiveLogsUserActivityGraph.vue
Normal file
129
src/components/LiveLogsUserActivityGraph.vue
Normal 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>
|
|
@ -1,16 +1,22 @@
|
|||
<script setup>
|
||||
import { ref, watch } from "vue"
|
||||
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode } from "@/socket";
|
||||
import { ref, watch, computed } from "vue"
|
||||
import { notifications, userCount, notificationCounter, notificationAPICounter, toggleVerboseMode, toggleApiQueryMode } 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'
|
||||
|
@ -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="mr-1">
|
||||
<FontAwesomeIcon :icon="faUser" size="sm"></FontAwesomeIcon>
|
||||
User online:
|
||||
Players:
|
||||
</span>
|
||||
<span class="font-bold">{{ userCount }}</span>
|
||||
</span>
|
||||
|
@ -52,13 +58,22 @@
|
|||
<span class="font-bold">{{ notificationAPICounter }}</span>
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<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"/>
|
||||
<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"/>
|
||||
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 ">
|
||||
|
|
83
src/components/TheLiveLogsActivityGraph.vue
Normal file
83
src/components/TheLiveLogsActivityGraph.vue
Normal 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>
|
|
@ -2,7 +2,8 @@
|
|||
import { ref, computed } from "vue";
|
||||
import { active_exercises as exercises, progresses, setCompletedState } from "@/socket";
|
||||
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([])
|
||||
|
||||
|
@ -63,10 +64,11 @@
|
|||
<th
|
||||
v-for="(task, task_index) in exercise.tasks"
|
||||
: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">
|
||||
<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>
|
||||
</div>
|
||||
</th>
|
||||
|
@ -84,29 +86,58 @@
|
|||
</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-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>
|
||||
<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>
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-for="(task, task_index) in exercise.tasks"
|
||||
: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
|
||||
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)"
|
||||
>
|
||||
<span class="flex flex-col">
|
||||
<span class="text-nowrap">
|
||||
<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'}`"
|
||||
/>
|
||||
<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>
|
||||
</span>
|
||||
</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 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"
|
||||
|
|
|
@ -1,16 +1,46 @@
|
|||
<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>
|
||||
|
||||
<template>
|
||||
<span class="flex flex-col justify-center mt-1">
|
||||
<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-50 bg-red-600': !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>
|
||||
</template>
|
|
@ -2,10 +2,12 @@
|
|||
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(true)
|
||||
const darkMode = ref(darkModeOn.value)
|
||||
|
||||
watch(darkMode, (newValue) => {
|
||||
darkModeOn.value = newValue
|
||||
if (newValue) {
|
||||
document.getElementsByTagName('body')[0].classList.add('dark')
|
||||
} else {
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import './assets/main.css'
|
||||
import VueApexCharts from "vue3-apexcharts";
|
||||
|
||||
import { createApp } from '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
4
src/settings.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { ref, computed } from 'vue'
|
||||
|
||||
export const darkModeOn = ref(true)
|
||||
export const darkModeEnabled = computed(() => darkModeOn.value)
|
|
@ -3,13 +3,17 @@ 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:4000";
|
||||
const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:40001";
|
||||
const MAX_LIVE_LOG = 30
|
||||
|
||||
const initial_state = {
|
||||
notificationEvents: [],
|
||||
notificationCounter: 0,
|
||||
notificationAPICounter: 0,
|
||||
notificationHistory: [],
|
||||
notificationHistoryConfig: {},
|
||||
userActivity: {},
|
||||
userActivityConfig: {},
|
||||
exercises: [],
|
||||
selected_exercises: [],
|
||||
progresses: {},
|
||||
|
@ -18,7 +22,8 @@ const initial_state = {
|
|||
|
||||
const state = reactive({ ...initial_state });
|
||||
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 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);
|
||||
|
@ -50,6 +60,7 @@ export function fullReload() {
|
|||
getSelectedExercises()
|
||||
getNotifications()
|
||||
getProgress()
|
||||
getUsersActivity()
|
||||
}
|
||||
|
||||
export function setCompletedState(completed, user_id, exec_uuid, task_uuid) {
|
||||
|
@ -81,6 +92,10 @@ 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})
|
||||
|
||||
|
@ -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() {
|
||||
state.diagnostic = {}
|
||||
socket.emit("get_diagnostic", (diagnostic) => {
|
||||
|
@ -151,6 +174,13 @@ 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", () => {
|
||||
|
@ -177,6 +207,20 @@ 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) {
|
||||
|
|
|
@ -9,6 +9,12 @@ export default {
|
|||
transitionProperty: {
|
||||
'width': 'width'
|
||||
},
|
||||
screens: {
|
||||
'3xl': '1800px',
|
||||
},
|
||||
fontSize: {
|
||||
'xxs': '0.6rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
Loading…
Reference in a new issue