Compare commits
2 commits
b512023dd5
...
d39fe6f20c
Author | SHA1 | Date | |
---|---|---|---|
|
d39fe6f20c | ||
|
9d9bf6f211 |
31 changed files with 5755 additions and 0 deletions
14
.eslintrc.cjs
Normal file
14
.eslintrc.cjs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
/* eslint-env node */
|
||||||
|
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
'extends': [
|
||||||
|
'plugin:vue/vue3-essential',
|
||||||
|
'eslint:recommended',
|
||||||
|
'@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest'
|
||||||
|
}
|
||||||
|
}
|
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
__pycache__
|
||||||
|
venv
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
8
.prettierrc.json
Normal file
8
.prettierrc.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
3
REQUIREMENTS
Normal file
3
REQUIREMENTS
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
pyzmq
|
||||||
|
python-socketio
|
||||||
|
eventlet
|
1
active_exercises/basic-event-creation.json
Symbolic link
1
active_exercises/basic-event-creation.json
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../exercises/basic-event-creation.json
|
1
active_exercises/basic-filtering.json
Symbolic link
1
active_exercises/basic-filtering.json
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../exercises/basic-filtering.json
|
11
config.py
Normal file
11
config.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
|
||||||
|
misp_url = 'https://localhost/'
|
||||||
|
misp_apikey = 'FI4gCRghRZvLVjlLPLTFZ852x2njkkgPSz0zQ3E0'
|
||||||
|
misp_skipssl = True
|
||||||
|
|
||||||
|
live_logs_accepted_scope = {
|
||||||
|
'rest_client_history': '*',
|
||||||
|
'events': ['add', 'edit', 'delete', 'restSearch',],
|
||||||
|
'attributes': ['add', 'edit', 'delete', 'restSearch',],
|
||||||
|
'tags': '*',
|
||||||
|
}
|
15
db.py
Normal file
15
db.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import collections
|
||||||
|
|
||||||
|
|
||||||
|
USER_ID_TO_EMAIL_MAPPING = {}
|
||||||
|
USER_ID_TO_AUTHKEY_MAPPING = {}
|
||||||
|
ALL_EXERCICES = []
|
||||||
|
INJECT_BY_UUID = {}
|
||||||
|
INJECT_SEQUENCE_BY_INJECT_UUID = {}
|
||||||
|
INJECT_REQUIREMENTS_BY_INJECT_UUID = {}
|
||||||
|
EXERCISES_STATUS = {}
|
||||||
|
PROGRESS = {
|
||||||
|
}
|
||||||
|
NOTIFICATION_MESSAGES = collections.deque([], 30)
|
337
exercise.py
Normal file
337
exercise.py
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Union
|
||||||
|
import db
|
||||||
|
from inject_evaluator import eval_data_filtering, eval_query_comparison
|
||||||
|
import misp_api
|
||||||
|
import config
|
||||||
|
|
||||||
|
ACTIVE_EXERCISES_DIR = "active_exercises"
|
||||||
|
|
||||||
|
|
||||||
|
def load_exercises() -> bool:
|
||||||
|
db.ALL_EXERCICES = read_exercise_dir()
|
||||||
|
init_inject_flow()
|
||||||
|
init_exercises_tasks()
|
||||||
|
# mark_task_completed(10, "4703a4b2-0ae4-47f3-9dc3-91250be60156", "e2216993-6192-4e7c-ae30-97cfe9de61b4") # filtering - past 48h
|
||||||
|
# mark_task_completed(10, "29324587-db6c-4a73-a209-cf8c79871629", "a6b5cf88-ba93-4c3f-8265-04e00d53778e") # Data - Event creation
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def read_exercise_dir():
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
target_dir = script_dir / ACTIVE_EXERCISES_DIR
|
||||||
|
json_files = target_dir.glob("*.json")
|
||||||
|
exercises = []
|
||||||
|
for json_file in json_files:
|
||||||
|
with open(json_file) as f:
|
||||||
|
parsed_exercise = json.load(f)
|
||||||
|
exercises.append(parsed_exercise)
|
||||||
|
return exercises
|
||||||
|
|
||||||
|
|
||||||
|
def init_inject_flow():
|
||||||
|
for exercise in db.ALL_EXERCICES:
|
||||||
|
for inject in exercise['injects']:
|
||||||
|
inject['exercise_uuid'] = exercise['exercise']['uuid']
|
||||||
|
db.INJECT_BY_UUID[inject['uuid']] = inject
|
||||||
|
|
||||||
|
for exercise in db.ALL_EXERCICES:
|
||||||
|
for inject_flow in exercise['inject_flow']:
|
||||||
|
db.INJECT_REQUIREMENTS_BY_INJECT_UUID[inject_flow['inject_uuid']] = inject_flow['requirements']
|
||||||
|
db.INJECT_SEQUENCE_BY_INJECT_UUID[inject_flow['inject_uuid']] = []
|
||||||
|
for sequence in inject_flow['sequence'].get('followed_by', []):
|
||||||
|
db.INJECT_SEQUENCE_BY_INJECT_UUID[inject_flow['inject_uuid']].append(sequence)
|
||||||
|
|
||||||
|
|
||||||
|
def init_exercises_tasks():
|
||||||
|
for exercise in db.ALL_EXERCICES:
|
||||||
|
tasks = {}
|
||||||
|
for inject in exercise['injects']:
|
||||||
|
tasks[inject['uuid']] = {
|
||||||
|
"name": inject['name'],
|
||||||
|
"uuid": inject['uuid'],
|
||||||
|
"completed_by_user": [],
|
||||||
|
}
|
||||||
|
db.EXERCISES_STATUS[exercise['exercise']['uuid']] = {
|
||||||
|
'uuid': exercise['exercise']['uuid'],
|
||||||
|
'name': exercise['exercise']['name'],
|
||||||
|
'tasks': tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_exercises():
|
||||||
|
exercices = []
|
||||||
|
for exercise in db.ALL_EXERCICES:
|
||||||
|
tasks = []
|
||||||
|
for inject in exercise['injects']:
|
||||||
|
score = 0
|
||||||
|
try:
|
||||||
|
for inject_eval in inject['inject_evaluation']:
|
||||||
|
score += inject_eval['score_range'][1]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"name": inject['name'],
|
||||||
|
"uuid": inject['uuid'],
|
||||||
|
"score": score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exercices.append(
|
||||||
|
{
|
||||||
|
"name": exercise['exercise']['name'],
|
||||||
|
"uuid": exercise['exercise']['uuid'],
|
||||||
|
"description": exercise['exercise']['description'],
|
||||||
|
"level": exercise['exercise']['meta']['level'],
|
||||||
|
"priority": exercise['exercise']['meta'].get('priority', 50),
|
||||||
|
"tasks": tasks,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exercices = sorted(exercices, key=lambda d: d['priority'])
|
||||||
|
return exercices
|
||||||
|
|
||||||
|
|
||||||
|
def get_completed_tasks_for_user(user_id: int):
|
||||||
|
completion = get_completion_for_users()[user_id]
|
||||||
|
completed_tasks = {}
|
||||||
|
for exec_uuid, tasks in completion.items():
|
||||||
|
completed_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if completed]
|
||||||
|
return completed_tasks
|
||||||
|
|
||||||
|
def get_incomplete_tasks_for_user(user_id: int):
|
||||||
|
completion = get_completion_for_users()[user_id]
|
||||||
|
incomplete_tasks = {}
|
||||||
|
for exec_uuid, tasks in completion.items():
|
||||||
|
incomplete_tasks[exec_uuid] = [task_uuid for task_uuid, completed in tasks.items() if not completed]
|
||||||
|
return incomplete_tasks
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_tasks_for_user(user_id: int) -> list[str]:
|
||||||
|
available_tasks = []
|
||||||
|
completed = get_completed_tasks_for_user(user_id)
|
||||||
|
incomplete = get_incomplete_tasks_for_user(user_id)
|
||||||
|
for exec_uuid, tasks in incomplete.items():
|
||||||
|
for task_uuid in tasks:
|
||||||
|
requirements = db.INJECT_REQUIREMENTS_BY_INJECT_UUID[task_uuid]
|
||||||
|
requirement_met = 'inject_uuid' not in requirements or requirements['inject_uuid'] in completed[exec_uuid]
|
||||||
|
if requirement_met:
|
||||||
|
available_tasks.append(task_uuid)
|
||||||
|
return available_tasks
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_action(data: dict):
|
||||||
|
if 'Log' in data:
|
||||||
|
data = data['Log']
|
||||||
|
if 'model' in data and 'action' in data:
|
||||||
|
return (data['model'], data['action'],)
|
||||||
|
return (None, None,)
|
||||||
|
|
||||||
|
def is_accepted_query(data: dict) -> bool:
|
||||||
|
model, action = get_model_action(data)
|
||||||
|
if model in ['Event', 'Attribute', 'Object', 'Tag',]:
|
||||||
|
if action in ['add', 'edit', 'delete',]:
|
||||||
|
if data['Log']['change'].startswith('attribute_count'):
|
||||||
|
return False
|
||||||
|
if data['Log']['change'].startswith('Validation errors:'):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
if data.get('user_agent', None) == 'misp-exercise-dashboard':
|
||||||
|
return None
|
||||||
|
url = data.get('url', None)
|
||||||
|
if url is not None:
|
||||||
|
return url in [
|
||||||
|
'/attributes/restSearch',
|
||||||
|
'/events/restSearch',
|
||||||
|
]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_completion_for_users():
|
||||||
|
completion_per_user = {int(user_id): {} for user_id in db.USER_ID_TO_EMAIL_MAPPING.keys()}
|
||||||
|
for exercise_status in db.EXERCISES_STATUS.values():
|
||||||
|
for user_id in completion_per_user.keys():
|
||||||
|
completion_per_user[int(user_id)][exercise_status['uuid']] = {}
|
||||||
|
|
||||||
|
for task in exercise_status['tasks'].values():
|
||||||
|
for user_id in completion_per_user.keys():
|
||||||
|
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = False
|
||||||
|
for user_id in task['completed_by_user']:
|
||||||
|
completion_per_user[int(user_id)][exercise_status['uuid']][task['uuid']] = True
|
||||||
|
|
||||||
|
return completion_per_user
|
||||||
|
|
||||||
|
|
||||||
|
def get_score_for_task_completion(tasks_completion: dict) -> int:
|
||||||
|
score = 0
|
||||||
|
for inject_uuid, completed in tasks_completion.items():
|
||||||
|
if not completed:
|
||||||
|
continue
|
||||||
|
inject = db.INJECT_BY_UUID[inject_uuid]
|
||||||
|
try:
|
||||||
|
for inject_eval in inject['inject_evaluation']:
|
||||||
|
score += inject_eval['score_range'][1]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def mark_task_completed(user_id: int, exercise_uuid: str , task_uuid: str):
|
||||||
|
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):
|
||||||
|
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():
|
||||||
|
completion_for_users = get_completion_for_users()
|
||||||
|
progress = {}
|
||||||
|
for user_id in completion_for_users:
|
||||||
|
progress[user_id] = {
|
||||||
|
'email': db.USER_ID_TO_EMAIL_MAPPING[user_id],
|
||||||
|
'exercises': {},
|
||||||
|
}
|
||||||
|
for exec_uuid, tasks_completion in completion_for_users[user_id].items():
|
||||||
|
progress[user_id]['exercises'][exec_uuid] = {
|
||||||
|
'tasks_completion': tasks_completion,
|
||||||
|
'score': get_score_for_task_completion(tasks_completion),
|
||||||
|
}
|
||||||
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if not success:
|
||||||
|
return False
|
||||||
|
mark_task_completed(user_id, inject['exercise_uuid'], inject['uuid'])
|
||||||
|
print(f'Task success: {inject['uuid']}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_evaluation_context(user_id: int, inject_evaluation: dict, data: dict, context: dict) -> bool:
|
||||||
|
if 'evaluation_context' not in inject_evaluation:
|
||||||
|
return True
|
||||||
|
if 'request_is_rest' in inject_evaluation['evaluation_context']:
|
||||||
|
if 'request_is_rest' in context:
|
||||||
|
if inject_evaluation['evaluation_context']['request_is_rest'] == context['request_is_rest']:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print('Request type does not match state of `request_is_rest`')
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print('Unknown request type')
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
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)
|
||||||
|
if data_to_validate is None:
|
||||||
|
print('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':
|
||||||
|
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 False
|
||||||
|
|
||||||
|
|
||||||
|
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':
|
||||||
|
perfomed_query = parse_performed_query_from_log(data)
|
||||||
|
data_to_validate = fetch_data_for_query_comparison(user_id, inject_evaluation, perfomed_query)
|
||||||
|
return data_to_validate
|
||||||
|
|
||||||
|
|
||||||
|
def parse_event_id_from_log(data: dict) -> Union[int, None]:
|
||||||
|
event_id_from_change_field_regex = r".*event_id \(.*\) => \((\d+)\).*"
|
||||||
|
if 'Log' in data:
|
||||||
|
log = data['Log']
|
||||||
|
if 'change' in log:
|
||||||
|
event_id_search = re.search(event_id_from_change_field_regex, log['change'], re.IGNORECASE)
|
||||||
|
if event_id_search is None:
|
||||||
|
return None
|
||||||
|
event_id = event_id_search.group(1)
|
||||||
|
return event_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_performed_query_from_log(data: dict) -> Union[dict, None]:
|
||||||
|
performed_query = {
|
||||||
|
'request_method': '',
|
||||||
|
'url': '',
|
||||||
|
'payload': {},
|
||||||
|
}
|
||||||
|
if 'request' in data:
|
||||||
|
request = data['request']
|
||||||
|
performed_query['request_method'] = data.get('request_method', None)
|
||||||
|
performed_query['url'] = data.get('url', None)
|
||||||
|
if request.startswith('application/json\n\n'):
|
||||||
|
query_raw = request.replace('application/json\n\n', '')
|
||||||
|
try:
|
||||||
|
payload = json.loads(query_raw)
|
||||||
|
performed_query['payload'] = payload
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if performed_query['request_method'] is not None and performed_query['url'] is not None:
|
||||||
|
return performed_query
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
if 'evaluation_context' not in inject_evaluation and 'query_context' not in inject_evaluation['evaluation_context']:
|
||||||
|
return None
|
||||||
|
query_context = inject_evaluation['evaluation_context']['query_context']
|
||||||
|
expected_method = query_context['request_method']
|
||||||
|
expected_url = query_context['url']
|
||||||
|
expected_payload = inject_evaluation['parameters'][0]
|
||||||
|
expected_data = 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,
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def check_active_tasks(user_id: int, data: dict, context: dict) -> bool:
|
||||||
|
succeeded_once = False
|
||||||
|
available_tasks = get_available_tasks_for_user(user_id)
|
||||||
|
for task_uuid in available_tasks:
|
||||||
|
inject = db.INJECT_BY_UUID[task_uuid]
|
||||||
|
print(f'checking: {inject['name']}')
|
||||||
|
completed = check_inject(user_id, inject, data, context)
|
||||||
|
if completed:
|
||||||
|
succeeded_once = True
|
||||||
|
return succeeded_once
|
392
exercises/basic-event-creation.json
Normal file
392
exercises/basic-event-creation.json
Normal file
|
@ -0,0 +1,392 @@
|
||||||
|
{
|
||||||
|
"exercise": {
|
||||||
|
"description": "Simple Data Creation: Creation of an Event using the API",
|
||||||
|
"expanded": "Simple Data Creation: Creation of an Event using the API",
|
||||||
|
"meta": {
|
||||||
|
"author": "MISP Project",
|
||||||
|
"level": "beginner",
|
||||||
|
"priority": 1
|
||||||
|
},
|
||||||
|
"name": "Simple Data Creation Via the API",
|
||||||
|
"namespace": "data-model",
|
||||||
|
"tags": [
|
||||||
|
"exercise:software-scope=\"misp\"",
|
||||||
|
"state:production"
|
||||||
|
],
|
||||||
|
"total_duration": "7200",
|
||||||
|
"uuid": "29324587-db6c-4a73-a209-cf8c79871629",
|
||||||
|
"version": "20240624"
|
||||||
|
},
|
||||||
|
"inject_flow": [
|
||||||
|
{
|
||||||
|
"description": "Event Creation",
|
||||||
|
"inject_uuid": "a6b5cf88-ba93-4c3f-8265-04e00d53778e",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
"00275360-d84a-4ce7-84fc-98baefd13776"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
"startex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Attributes Creation",
|
||||||
|
"inject_uuid": "00275360-d84a-4ce7-84fc-98baefd13776",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {
|
||||||
|
"inject_uuid": "a6b5cf88-ba93-4c3f-8265-04e00d53778e",
|
||||||
|
"resolution_requirement": "MISP Event created"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
"be1c3d25-e0df-4492-bdc1-f2e825194ef3"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Object Creation",
|
||||||
|
"inject_uuid": "be1c3d25-e0df-4492-bdc1-f2e825194ef3",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {
|
||||||
|
"inject_uuid": "a6b5cf88-ba93-4c3f-8265-04e00d53778e",
|
||||||
|
"resolution_requirement": "MISP Event created"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
"cf149a8c-5601-4eec-aea3-5142170d309b"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Edition to `org-only`",
|
||||||
|
"inject_uuid": "cf149a8c-5601-4eec-aea3-5142170d309b",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {
|
||||||
|
"inject_uuid": "00275360-d84a-4ce7-84fc-98baefd13776",
|
||||||
|
"resolution_requirement": "MISP Attributes created"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
"b4a8c490-4f0a-4a33-bee1-044b9f659e83"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Tagging `tlp:green`",
|
||||||
|
"inject_uuid": "b4a8c490-4f0a-4a33-bee1-044b9f659e83",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {
|
||||||
|
"inject_uuid": "00275360-d84a-4ce7-84fc-98baefd13776",
|
||||||
|
"resolution_requirement": "MISP Attributes created"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inject_payloads": [
|
||||||
|
],
|
||||||
|
"injects": [
|
||||||
|
{
|
||||||
|
"action": "event_creation",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"Event.info": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
"event",
|
||||||
|
"API"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "MISP Event created",
|
||||||
|
"evaluation_strategy": "data_filtering",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
20
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Event Creation",
|
||||||
|
"target_tool": "MISP",
|
||||||
|
"uuid": "a6b5cf88-ba93-4c3f-8265-04e00d53778e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "attribute_creation",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"Event.info": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
"event",
|
||||||
|
"API"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Event.Attribute": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"type": "ip-dst",
|
||||||
|
"value": "1.2.3.4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "domain",
|
||||||
|
"value": "evil.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "filename",
|
||||||
|
"value": "evil.exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "MISP Attributes created",
|
||||||
|
"evaluation_strategy": "data_filtering",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
30
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Attributes Creation",
|
||||||
|
"target_tool": "MISP",
|
||||||
|
"uuid": "00275360-d84a-4ce7-84fc-98baefd13776"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "object_creation",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"Event.info": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
"event",
|
||||||
|
"API"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Event.Object": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "domain-ip",
|
||||||
|
"template_uuid": "43b3b146-77eb-4931-b4cc-b66c60f28734"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "MISP Object created`",
|
||||||
|
"evaluation_strategy": "data_filtering",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"Event.info": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
"event",
|
||||||
|
"API"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Event.Object[name=\"domain-ip\"].Attribute": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"object_relation": "ip",
|
||||||
|
"value": "4.3.2.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"object_relation": "domain",
|
||||||
|
"value": "foobar.baz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"object_relation": "text",
|
||||||
|
"value": "Classified information"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "MISP Object's Attributes created`",
|
||||||
|
"evaluation_strategy": "data_filtering",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Object Creation",
|
||||||
|
"target_tool": "MISP",
|
||||||
|
"uuid": "be1c3d25-e0df-4492-bdc1-f2e825194ef3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "edition_org_only",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"Event.info": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
"event",
|
||||||
|
"API"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Event.Attribute": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"type": "ip-dst",
|
||||||
|
"value": "1.2.3.4",
|
||||||
|
"distribution": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "MISP Edition `org-only` done",
|
||||||
|
"evaluation_strategy": "data_filtering",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Edition to `org-only`",
|
||||||
|
"target_tool": "MISP",
|
||||||
|
"uuid": "cf149a8c-5601-4eec-aea3-5142170d309b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "tagging_tlp_green",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"Event.info": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
"event",
|
||||||
|
"API"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Event.Attribute": {
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"type": "ip-dst",
|
||||||
|
"value": "1.2.3.4",
|
||||||
|
"distribution": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Event.Attribute[value=\"1.2.3.4\"].Tag": {
|
||||||
|
"JQ": "jq '.Event.Attribute[] | select(.value == \"1.2.3.4\") | .Tag'",
|
||||||
|
"comparison": "contains",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "tlp:green"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "MISP Tagging `tlp:green` done",
|
||||||
|
"evaluation_strategy": "data_filtering",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
20
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Tagging `tlp:green`",
|
||||||
|
"target_tool": "MISP",
|
||||||
|
"uuid": "b4a8c490-4f0a-4a33-bee1-044b9f659e83"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
231
exercises/basic-filtering.json
Normal file
231
exercises/basic-filtering.json
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
{
|
||||||
|
"exercise": {
|
||||||
|
"description": "Basic Filtering: Usage of the API to filter data",
|
||||||
|
"expanded": "Basic Filtering: Usage of the API to filter data",
|
||||||
|
"meta": {
|
||||||
|
"author": "MISP Project",
|
||||||
|
"level": "beginner",
|
||||||
|
"priority": 2
|
||||||
|
},
|
||||||
|
"name": "Basic Filtering - Usage of the API to filter data",
|
||||||
|
"namespace": "data-model",
|
||||||
|
"tags": [
|
||||||
|
"exercise:software-scope=\"misp\"",
|
||||||
|
"state:production"
|
||||||
|
],
|
||||||
|
"total_duration": "7200",
|
||||||
|
"uuid": "4703a4b2-0ae4-47f3-9dc3-91250be60156",
|
||||||
|
"version": "20240624"
|
||||||
|
},
|
||||||
|
"inject_flow": [
|
||||||
|
{
|
||||||
|
"description": "Get Published in the past 48h",
|
||||||
|
"inject_uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
"caf68c86-65ed-4df3-99b8-7e346fa498ba"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
"startex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "IP IoCs changed in the past 48h in CSV",
|
||||||
|
"inject_uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {
|
||||||
|
"inject_uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
"3e96fb13-4aba-448c-8d79-efb93392cc88"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "First 20 Attribute with TLP lower than `amber`",
|
||||||
|
"inject_uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {
|
||||||
|
"inject_uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
"1da0fdc8-9d0d-4618-a811-66491e196833"
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Event count with `Phishing - T1566` involved",
|
||||||
|
"inject_uuid": "1da0fdc8-9d0d-4618-a811-66491e196833",
|
||||||
|
"reporting_callback": [],
|
||||||
|
"requirements": {
|
||||||
|
"inject_uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"completion_trigger": [
|
||||||
|
"time_expiration",
|
||||||
|
"completion"
|
||||||
|
],
|
||||||
|
"followed_by": [
|
||||||
|
],
|
||||||
|
"trigger": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timing": {
|
||||||
|
"triggered_at": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inject_payloads": [
|
||||||
|
],
|
||||||
|
"injects": [
|
||||||
|
{
|
||||||
|
"action": "published_48",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"publish_timestamp": "48h",
|
||||||
|
"published": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "Published 48h retreived",
|
||||||
|
"evaluation_strategy": "query_comparison",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true,
|
||||||
|
"query_context": {
|
||||||
|
"url": "/attributes/restSearch",
|
||||||
|
"request_method": "POST"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
20
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Get Published in the past 48h",
|
||||||
|
"target_tool": "MISP-query",
|
||||||
|
"uuid": "e2216993-6192-4e7c-ae30-97cfe9de61b4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "ip_csv",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": ["ip-src", "ip-dst"],
|
||||||
|
"timestamp": "48h",
|
||||||
|
"to_ids": 1,
|
||||||
|
"returnFormat": "csv"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "IP CSV retrieved",
|
||||||
|
"evaluation_strategy": "query_comparison",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true,
|
||||||
|
"query_context": {
|
||||||
|
"url": "/attributes/restSearch",
|
||||||
|
"request_method": "POST"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
40
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "IP IoCs changed in the past 48h in CSV",
|
||||||
|
"target_tool": "MISP-query",
|
||||||
|
"uuid": "caf68c86-65ed-4df3-99b8-7e346fa498ba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "20_tlp",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"tags": ["tlp:white", "tlp:clear", "tlp:green"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "20 Attribute tagged retrieved",
|
||||||
|
"evaluation_strategy": "query_comparison",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true,
|
||||||
|
"query_context": {
|
||||||
|
"url": "/attributes/restSearch",
|
||||||
|
"request_method": "POST"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
30
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "First 20 Attribute with TLP lower than `amber`",
|
||||||
|
"target_tool": "MISP-query",
|
||||||
|
"uuid": "3e96fb13-4aba-448c-8d79-efb93392cc88"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "phishing_count",
|
||||||
|
"inject_evaluation": [
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"returnFormat": "attack",
|
||||||
|
"tags": ["misp-galaxy:mitre-attack-pattern=\"Phishing - T1566\""]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result": "Phising counted",
|
||||||
|
"evaluation_strategy": "query_comparison",
|
||||||
|
"evaluation_context": {
|
||||||
|
"request_is_rest": true,
|
||||||
|
"query_context": {
|
||||||
|
"url": "/events/restSearch",
|
||||||
|
"request_method": "POST"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"score_range": [
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Event count with `Phishing - T1566` involved",
|
||||||
|
"target_tool": "MISP-query",
|
||||||
|
"uuid": "1da0fdc8-9d0d-4618-a811-66491e196833"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
13
index.html
Normal file
13
index.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
112
inject_evaluator.py
Normal file
112
inject_evaluator.py
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from typing import Union
|
||||||
|
import jq
|
||||||
|
import re
|
||||||
|
import operator
|
||||||
|
|
||||||
|
|
||||||
|
def jq_extract(path: str, data: dict):
|
||||||
|
path = '.' + path if not path.startswith('.') else path
|
||||||
|
return jq.compile(path).input_value(data).first()
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
## Data Filtering
|
||||||
|
##
|
||||||
|
|
||||||
|
def condition_satisfied(evaluation_config: dict, data_to_validate: Union[dict, list, str]) -> bool:
|
||||||
|
if type(data_to_validate) is str:
|
||||||
|
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)
|
||||||
|
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 False
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if comparison_type == 'contains':
|
||||||
|
values = [v.lower() for v in values]
|
||||||
|
data_to_validate = data_to_validate.lower()
|
||||||
|
data_to_validate_set = set(data_to_validate.split())
|
||||||
|
values_set = set(values)
|
||||||
|
intersection = data_to_validate_set & values_set
|
||||||
|
return len(intersection) == len(values_set)
|
||||||
|
elif comparison_type == 'equals':
|
||||||
|
return data_to_validate == values[0]
|
||||||
|
elif comparison_type == 'regex':
|
||||||
|
return re.fullmatch(values[0], data_to_validate)
|
||||||
|
elif comparison_type == 'count':
|
||||||
|
return len(data_to_validate) == values[0]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def eval_condition_list(evaluation_config: dict, data_to_validate: str) -> bool:
|
||||||
|
comparison_type = evaluation_config['comparison']
|
||||||
|
values = evaluation_config['values']
|
||||||
|
comparators = {
|
||||||
|
'<': operator.lt,
|
||||||
|
'<=': operator.le,
|
||||||
|
'>': operator.gt,
|
||||||
|
'>=': operator.ge,
|
||||||
|
'=': operator.eq,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(values) == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data_to_validate_set = set(data_to_validate)
|
||||||
|
values_set = set(values)
|
||||||
|
|
||||||
|
if comparison_type == 'contains':
|
||||||
|
intersection = data_to_validate_set & values_set
|
||||||
|
return len(intersection) == len(values_set)
|
||||||
|
elif comparison_type == 'equals':
|
||||||
|
intersection = data_to_validate_set & values_set
|
||||||
|
return len(intersection) == len(values_set) and len(intersection) == len(data_to_validate_set)
|
||||||
|
elif comparison_type == 'count':
|
||||||
|
if values[0].isdigit():
|
||||||
|
return len(data_to_validate) == values[0]
|
||||||
|
elif values[0][0] in comparators.keys():
|
||||||
|
count = len(data_to_validate)
|
||||||
|
value_operator = values[0][0]
|
||||||
|
value = int(values[0][1:])
|
||||||
|
return comparators[value_operator](count, value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def eval_condition_dict(evaluation_config: dict, data_to_validate: str) -> bool:
|
||||||
|
print('Condition on dict not supported yet')
|
||||||
|
comparison_type = evaluation_config['comparison']
|
||||||
|
if comparison_type == 'contains':
|
||||||
|
pass
|
||||||
|
elif comparison_type == 'equals':
|
||||||
|
pass
|
||||||
|
elif comparison_type == 'count':
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
data_to_validate = jq_extract(evaluation_path, data)
|
||||||
|
if data_to_validate is None:
|
||||||
|
return False
|
||||||
|
if not condition_satisfied(evaluation_config, data_to_validate):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
## Query comparison
|
||||||
|
##
|
||||||
|
|
||||||
|
def eval_query_comparison(user_id: int, expected_data, data_to_validate) -> bool:
|
||||||
|
return expected_data == data_to_validate
|
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
46
misp_api.py
Normal file
46
misp_api.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Union
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
import requests # type: ignore
|
||||||
|
import requests.adapters # type: ignore
|
||||||
|
from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore
|
||||||
|
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
|
|
||||||
|
from config import misp_url, misp_apikey, misp_skipssl
|
||||||
|
|
||||||
|
|
||||||
|
def get(url, data={}, api_key=misp_apikey):
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'misp-exercise-dashboard',
|
||||||
|
"Authorization": api_key,
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
full_url = urljoin(misp_url, url)
|
||||||
|
response = requests.get(full_url, data=data, headers=headers, verify=not misp_skipssl)
|
||||||
|
return response.json() if response.headers['content-type'] == 'application/json' else response.text
|
||||||
|
|
||||||
|
|
||||||
|
def post(url, data={}, api_key=misp_apikey):
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'misp-exercise-dashboard',
|
||||||
|
"Authorization": api_key,
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
full_url = urljoin(misp_url, url)
|
||||||
|
response = requests.post(full_url, data=json.dumps(data), headers=headers, verify=not misp_skipssl)
|
||||||
|
return response.json() if response.headers['content-type'] == 'application/json' else response.text
|
||||||
|
|
||||||
|
|
||||||
|
def getEvent(event_id: int) -> Union[None, dict]:
|
||||||
|
return get(f'/events/view/{event_id}')
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
return get(url, payload, api_key=authkey)
|
133
notification.py
Normal file
133
notification.py
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Union
|
||||||
|
import db
|
||||||
|
import config
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
|
||||||
|
def get_notifications() -> list[dict]:
|
||||||
|
return list(db.NOTIFICATION_MESSAGES)
|
||||||
|
|
||||||
|
|
||||||
|
def record_notification(notification: dict):
|
||||||
|
db.NOTIFICATION_MESSAGES.appendleft(notification)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_id(data: dict):
|
||||||
|
if 'user_id' in data:
|
||||||
|
return int(data['user_id'])
|
||||||
|
if 'Log' in data:
|
||||||
|
data = data['Log']
|
||||||
|
if 'user_id' in data:
|
||||||
|
return int(data['user_id'])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_email_id_pair(data: dict):
|
||||||
|
if 'Log' in data:
|
||||||
|
data = data['Log']
|
||||||
|
if 'email' in data and 'user_id' in data:
|
||||||
|
return (int(data['user_id']), data['email'],)
|
||||||
|
return (None, None,)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_authkey_id_pair(data: dict):
|
||||||
|
authkey_title_regex = r".*API key.*\((\w+)\)"
|
||||||
|
if 'Log' in data:
|
||||||
|
data = data['Log']
|
||||||
|
if 'user_id' in data and 'title' in data :
|
||||||
|
if data['title'].startswith('Successful authentication using API key'):
|
||||||
|
authkey_search = re.search(authkey_title_regex, data['title'], re.IGNORECASE)
|
||||||
|
authkey = authkey_search.group(1)
|
||||||
|
return (int(data['user_id']), authkey,)
|
||||||
|
return (None, None,)
|
||||||
|
|
||||||
|
|
||||||
|
def is_http_request(data: dict) -> bool:
|
||||||
|
if ('url' in data and
|
||||||
|
'request_method' in data and
|
||||||
|
'response_code' in data and
|
||||||
|
'user_id' in data):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_content_type(data: dict) -> Union[None, str]:
|
||||||
|
if 'request' not in data:
|
||||||
|
return None
|
||||||
|
request_content = data['request']
|
||||||
|
content_type, _ = request_content.split('\n\n')
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
|
def clean_form_urlencoded_data(post_body_parsed: dict) -> dict:
|
||||||
|
cleaned = {}
|
||||||
|
for k, v in post_body_parsed.items():
|
||||||
|
if k.startswith('data[') and not k.startswith('data[_'):
|
||||||
|
clean_k = '.'.join([k for k in re.split(r'[\[\]]', k) if k != ''][1:])
|
||||||
|
clean_v = v[0] if type(v) is list and len(v) == 1 else v
|
||||||
|
cleaned[clean_k] = clean_v
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_post_body(data: dict) -> dict:
|
||||||
|
if 'request' not in data:
|
||||||
|
return {}
|
||||||
|
request_content = data['request']
|
||||||
|
content_type, post_body = request_content.split('\n\n')
|
||||||
|
if content_type == 'application/json':
|
||||||
|
post_body_parsed = json.loads(post_body)
|
||||||
|
return post_body_parsed
|
||||||
|
elif content_type == 'application/x-www-form-urlencoded':
|
||||||
|
post_body_parsed = parse_qs(post_body)
|
||||||
|
post_body_clean = clean_form_urlencoded_data(post_body_parsed)
|
||||||
|
return post_body_clean
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_api_request(data: dict) -> bool:
|
||||||
|
content_type = get_content_type(data)
|
||||||
|
return content_type == 'application/json'
|
||||||
|
|
||||||
|
|
||||||
|
def get_notification_message(data: dict) -> dict:
|
||||||
|
user = db.USER_ID_TO_EMAIL_MAPPING.get(int(data['user_id']), '?')
|
||||||
|
time = data['created'].split(' ')[1].split('.')[0]
|
||||||
|
url = data['url']
|
||||||
|
http_method = data.get('request_method', 'GET')
|
||||||
|
response_code = data.get('response_code', '?')
|
||||||
|
user_agent = data.get('user_agent', '?')
|
||||||
|
_, action = get_scope_action_from_url(url)
|
||||||
|
http_method = 'DELETE' if http_method == 'POST' and action == 'delete' else http_method # small override for UI
|
||||||
|
payload = get_request_post_body(data)
|
||||||
|
return {
|
||||||
|
'user': user,
|
||||||
|
'time': time,
|
||||||
|
'url': url,
|
||||||
|
'http_method': http_method,
|
||||||
|
'user_agent': user_agent,
|
||||||
|
'is_api_request': is_api_request(data),
|
||||||
|
'response_code': response_code,
|
||||||
|
'payload': payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_scope_action_from_url(url) -> str:
|
||||||
|
split = url.split('/')
|
||||||
|
return (split[1], split[2],)
|
||||||
|
|
||||||
|
|
||||||
|
def is_accepted_notification(notification) -> bool:
|
||||||
|
if notification['user_agent'] == 'misp-exercise-dashboard':
|
||||||
|
return False
|
||||||
|
|
||||||
|
scope, action = get_scope_action_from_url(notification['url'])
|
||||||
|
if scope in config.live_logs_accepted_scope:
|
||||||
|
if config.live_logs_accepted_scope == '*':
|
||||||
|
return True
|
||||||
|
elif action in config.live_logs_accepted_scope[scope]:
|
||||||
|
return True
|
||||||
|
return False
|
3703
package-lock.json
generated
Normal file
3703
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "misp-exercise-dashboard",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||||
|
"format": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^6.5.2",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
|
"vue": "^3.4.29"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rushstack/eslint-patch": "^1.8.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"@vue/eslint-config-prettier": "^9.0.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.23.0",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"socket.io-client": "^4.7.5",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"vite": "^5.3.1"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
125
server.py
Executable file
125
server.py
Executable file
|
@ -0,0 +1,125 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import zmq
|
||||||
|
import socketio
|
||||||
|
import eventlet
|
||||||
|
from pprint import pprint
|
||||||
|
from eventlet.green import zmq as gzmq
|
||||||
|
|
||||||
|
import exercise as exercise_model
|
||||||
|
import notification as notification_model
|
||||||
|
import config
|
||||||
|
import db
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize ZeroMQ context and subscriber socket
|
||||||
|
context = gzmq.Context()
|
||||||
|
zsocket = context.socket(gzmq.SUB)
|
||||||
|
zmq_url = "tcp://localhost:50000" # Update this with your zmq publisher address
|
||||||
|
zsocket.connect(zmq_url)
|
||||||
|
zsocket.setsockopt_string(gzmq.SUBSCRIBE, '')
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize Socket.IO server
|
||||||
|
sio = socketio.Server(cors_allowed_origins='*', async_mode='eventlet')
|
||||||
|
app = socketio.WSGIApp(sio)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def connect(sid, environ):
|
||||||
|
print("Client connected: ", sid)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def disconnect(sid):
|
||||||
|
print("Client disconnected: ", sid)
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def get_exercises(sid):
|
||||||
|
return exercise_model.get_exercises()
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def get_progress(sid):
|
||||||
|
return exercise_model.get_progress()
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def get_notifications(sid):
|
||||||
|
return notification_model.get_notifications()
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def mark_task_completed(sid, payload):
|
||||||
|
return exercise_model.mark_task_completed(payload['user_id'], payload['exercise_uuid'], payload['task_uuid'])
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def mark_task_incomplete(sid, payload):
|
||||||
|
return exercise_model.mark_task_incomplete(payload['user_id'], payload['exercise_uuid'], payload['task_uuid'])
|
||||||
|
|
||||||
|
@sio.on('*')
|
||||||
|
def any_event(event, sid, data={}):
|
||||||
|
print('>> Unhandled event', event)
|
||||||
|
|
||||||
|
def handleMessage(topic, s, message):
|
||||||
|
data = json.loads(message)
|
||||||
|
|
||||||
|
if topic == 'misp_json_audit':
|
||||||
|
user_id, email = notification_model.get_user_email_id_pair(data)
|
||||||
|
if user_id is not None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
user_id, authkey = notification_model.get_user_authkey_id_pair(data)
|
||||||
|
if user_id is not None:
|
||||||
|
if authkey not in db.USER_ID_TO_AUTHKEY_MAPPING:
|
||||||
|
db.USER_ID_TO_AUTHKEY_MAPPING[user_id] = authkey
|
||||||
|
return
|
||||||
|
|
||||||
|
if notification_model.is_http_request(data):
|
||||||
|
notification = notification_model.get_notification_message(data)
|
||||||
|
if notification_model.is_accepted_notification(notification):
|
||||||
|
notification_model.record_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(data)
|
||||||
|
succeeded_once = exercise_model.check_active_tasks(user_id, data, context)
|
||||||
|
if succeeded_once:
|
||||||
|
sio.emit('refresh_score')
|
||||||
|
|
||||||
|
|
||||||
|
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']
|
||||||
|
elif 'authkey_id' in data:
|
||||||
|
context['request_is_rest'] = True
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
# Function to forward zmq messages to Socket.IO
|
||||||
|
def forward_zmq_to_socketio():
|
||||||
|
while True:
|
||||||
|
message = zsocket.recv_string()
|
||||||
|
topic, s, m = message.partition(" ")
|
||||||
|
try:
|
||||||
|
handleMessage(topic, s, m)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
exercises_loaded = exercise_model.load_exercises()
|
||||||
|
if not exercises_loaded:
|
||||||
|
print('Could not load exercises')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Start the forwarding in a separate thread
|
||||||
|
eventlet.spawn_n(forward_zmq_to_socketio)
|
||||||
|
|
||||||
|
# Run the Socket.IO server
|
||||||
|
eventlet.wsgi.server(eventlet.listen(('0.0.0.0', 3000)), app)
|
57
src/App.vue
Normal file
57
src/App.vue
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import TheDahboard from './components/TheDahboard.vue'
|
||||||
|
import { connectionState } from "@/socket";
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
const darkMode = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.getElementsByTagName('body')[0].classList.add('dark')
|
||||||
|
document.getElementById('app').classList.add('w-5/6')
|
||||||
|
})
|
||||||
|
|
||||||
|
const socketConnected = computed(() => connectionState.connected)
|
||||||
|
|
||||||
|
watch(darkMode, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
document.getElementsByTagName('body')[0].classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.getElementsByTagName('body')[0].classList.remove('dark')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<div class="absolute top-1 right-1">
|
||||||
|
<button
|
||||||
|
@click="darkMode = !darkMode"
|
||||||
|
class="mr-3 px-2 py-1 rounded-md focus-outline font-semibold bg-blue-600 text-slate-200 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faSun" class="mr-1" v-show="!darkMode"></FontAwesomeIcon>
|
||||||
|
<FontAwesomeIcon :icon="faMoon" class="mr-1" v-show="darkMode"></FontAwesomeIcon>
|
||||||
|
{{ darkMode ? 'Dark' : 'Light'}}
|
||||||
|
</button>
|
||||||
|
<span class="text-slate-900 dark:text-slate-400 shadow-blue-500/50">
|
||||||
|
<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-red-500">Disconnected</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TheDahboard />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
@apply flex;
|
||||||
|
@apply bg-slate-200;
|
||||||
|
@apply dark:bg-gray-700;
|
||||||
|
@apply text-slate-400;
|
||||||
|
@apply dark:text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
86
src/assets/base.css
Normal file
86
src/assets/base.css
Normal 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;
|
||||||
|
}
|
20
src/assets/main.css
Normal file
20
src/assets/main.css
Normal file
|
@ -0,0 +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;
|
||||||
|
}
|
||||||
|
} */
|
257
src/components/TheDahboard.vue
Normal file
257
src/components/TheDahboard.vue
Normal file
|
@ -0,0 +1,257 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { state as socketState, socket, resetState, connectionState } from "@/socket";
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
import { faCheck, faTimes, faSignal, faGraduationCap, faCloud, faCog, faUser, faCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
const exercises = ref([])
|
||||||
|
|
||||||
|
const notifications = computed(() => socketState.notificationEvents)
|
||||||
|
|
||||||
|
const progresses = computed(() => socketState.progresses)
|
||||||
|
|
||||||
|
const user_count = computed(() => Object.keys(socketState.progresses).length)
|
||||||
|
|
||||||
|
function toggle_completed(completed, user_id, exec_uuid, task_uuid) {
|
||||||
|
const payload = {
|
||||||
|
user_id: user_id,
|
||||||
|
exercise_uuid: exec_uuid,
|
||||||
|
task_uuid: task_uuid,
|
||||||
|
}
|
||||||
|
const event_name = !completed ? "mark_task_completed": "mark_task_incomplete"
|
||||||
|
socket.emit(event_name, payload, () => {
|
||||||
|
socket.emit("get_progress", (all_progress) => {
|
||||||
|
socketState.progresses = all_progress
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClassFromResponseCode(response_code) {
|
||||||
|
if (String(response_code).startsWith('2')) {
|
||||||
|
return 'text-green-500'
|
||||||
|
} else if (String(response_code).startsWith('5')) {
|
||||||
|
return 'text-red-600'
|
||||||
|
} else {
|
||||||
|
return 'text-amber-600'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullReload() {
|
||||||
|
socket.emit("get_exercises", (all_exercises) => {
|
||||||
|
exercises.value = all_exercises
|
||||||
|
})
|
||||||
|
socket.emit("get_notifications", (all_notifications) => {
|
||||||
|
socketState.notificationEvents = all_notifications
|
||||||
|
})
|
||||||
|
socket.emit("get_progress", (all_progress) => {
|
||||||
|
socketState.progresses = all_progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketConnected = computed(() => connectionState.connected)
|
||||||
|
watch(socketConnected, (isConnected) => {
|
||||||
|
if (isConnected) {
|
||||||
|
resetState()
|
||||||
|
fullReload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fullReload()
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="text-3xl font-bold text-center text-slate-600 dark:text-slate-300">MISP Exercise Dashboard</h1>
|
||||||
|
|
||||||
|
<h3 class="text-2xl mt-6 mb-2 font-bold text-blue-500 dark:text-blue-400">
|
||||||
|
<FontAwesomeIcon :icon="faGraduationCap"></FontAwesomeIcon>
|
||||||
|
Active Exercises
|
||||||
|
</h3>
|
||||||
|
<table
|
||||||
|
v-for="(exercise, exercise_index) in exercises"
|
||||||
|
:key="exercise.name"
|
||||||
|
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full mb-4"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th :colspan="2 + exercise.tasks.length" class="rounded-t-lg border-b border-slate-100 dark:border-slate-700 text-md p-3 pl-6 text-center dark:bg-blue-800 bg-blue-500 dark:text-slate-300 text-slate-100">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="dark:text-blue-200 text-slate-200 "># {{ exercise_index + 1 }}</span>
|
||||||
|
<span class="text-lg">{{ exercise.name }}</span>
|
||||||
|
<span class="">
|
||||||
|
Level: <span :class="{
|
||||||
|
'rounded-lg px-1 ml-2': true,
|
||||||
|
'dark:bg-sky-400 bg-sky-400 text-neutral-950': exercise.level == 'beginner',
|
||||||
|
'dark:bg-orange-400 bg-orange-400 text-neutral-950': exercise.level == 'advanced',
|
||||||
|
'dark:bg-red-600 bg-red-600 text-neutral-950': exercise.level == 'expert',
|
||||||
|
}">{{ exercise.level }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="font-medium text-slate-600 dark:text-slate-200">
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left">User</th>
|
||||||
|
<th
|
||||||
|
v-for="(task, task_index) in exercise.tasks"
|
||||||
|
:key="task.name"
|
||||||
|
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">Task {{ task_index + 1 }}</span>
|
||||||
|
<i class="text-center">{{ task.name }}</i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Progress</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="Object.keys(progresses).length == 0">
|
||||||
|
<td
|
||||||
|
:colspan="2 + exercise.tasks.length"
|
||||||
|
class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"
|
||||||
|
>
|
||||||
|
<i>- No user yet -</i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<template v-else>
|
||||||
|
<tr v-for="(progress, user_id) in progresses" :key="user_id">
|
||||||
|
<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-100 dark:border-slate-700 text-slate-500 dark:text-slate-400 p-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="select-none cursor-pointer"
|
||||||
|
@click="toggle_completed(progress.exercises[exercise.uuid].tasks_completion[task.uuid], user_id, exercise.uuid, task.uuid)"
|
||||||
|
>
|
||||||
|
<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-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].percentage" :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"
|
||||||
|
:style="`width: ${progress.exercises[exercise.uuid].score}%`"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="text-2xl mt-6 mb-2 font-bold text-blue-500 dark:text-blue-400">
|
||||||
|
<FontAwesomeIcon :icon="faSignal"></FontAwesomeIcon>
|
||||||
|
Live logs
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mb-2 flex flex-wrap gap-x-3">
|
||||||
|
<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:
|
||||||
|
</span>
|
||||||
|
<span class="font-bold">{{ user_count }}</span>
|
||||||
|
</span>
|
||||||
|
<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="faSignal" size="sm"></FontAwesomeIcon>
|
||||||
|
Total Queries:
|
||||||
|
</span>
|
||||||
|
<span class="font-bold">{{ socketState.notificationCounter }}</span>
|
||||||
|
</span>
|
||||||
|
<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="faCog" size="sm" :mask="faCloud" transform="shrink-7 left-1"></FontAwesomeIcon>
|
||||||
|
Total API Queries:
|
||||||
|
</span>
|
||||||
|
<span class="font-bold">{{ socketState.notificationAPICounter }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ">
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-6 text-left"></th>
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 pl-2 text-left">User</th>
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Time</th>
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">URL</th>
|
||||||
|
<th class="border-b border-slate-100 dark:border-slate-700 p-3 text-left">Payload</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="Object.keys(notifications).length == 0">
|
||||||
|
<td
|
||||||
|
colspan="5"
|
||||||
|
class="text-center border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-3 pl-6"
|
||||||
|
>
|
||||||
|
<i>- No logs yet -</i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<template v-else>
|
||||||
|
<tr v-for="(notification, index) in notifications" :key="index">
|
||||||
|
<td
|
||||||
|
class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-1 pl-2 w-12 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faCircle" size="xs"
|
||||||
|
:class="getClassFromResponseCode(notification.response_code)"
|
||||||
|
></FontAwesomeIcon>
|
||||||
|
<pre class="inline ml-1">{{ notification.response_code }}</pre>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-1 pl-2"
|
||||||
|
:title="notification.user_id"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-bold font-mono">{{ notification.user.split('@')[0] }}</span>
|
||||||
|
<span class="text-xs font-mono">@{{ notification.user.split('@')[1] }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-400 p-1">{{ notification.time }}</td>
|
||||||
|
<td class="border-b border-slate-100 dark:border-slate-700 text-sky-600 dark:text-sky-400 p-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span v-if="notification.http_method == 'POST'"
|
||||||
|
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center
|
||||||
|
dark:bg-amber-600 dark:text-neutral-100 bg-amber-600 text-neutral-100"
|
||||||
|
>POST</span>
|
||||||
|
<span v-else-if="notification.http_method == 'DELETE'"
|
||||||
|
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center
|
||||||
|
dark:bg-red-600 dark:text-neutral-100 bg-red-600 text-neutral-100"
|
||||||
|
>DEL</span>
|
||||||
|
<span v-else
|
||||||
|
class="p-1 rounded-md font-bold text-xs mr-2 w-10 inline-block text-center
|
||||||
|
dark:bg-blue-600 dark:text-neutral-100 bg-blue-600 text-neutral-100"
|
||||||
|
>{{ notification.http_method }}</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
v-if="notification.is_api_request"
|
||||||
|
class="text-slate-800 dark:text-slate-100 mr-1 inline-block"
|
||||||
|
:icon="faCog" :mask="faCloud" transform="shrink-7 left-1"
|
||||||
|
></FontAwesomeIcon>
|
||||||
|
<pre class="text-sm inline">{{ notification.url }}</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="border-b border-slate-100 dark:border-slate-700 text-slate-600 dark:text-slate-300 p-1">
|
||||||
|
<div v-if="notification.http_method == 'POST'"
|
||||||
|
class="border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 rounded-md"
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
class="p-1 text-xs"
|
||||||
|
>{{ JSON.stringify(notification.payload, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</template>
|
6
src/main.js
Normal file
6
src/main.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
63
src/socket.js
Normal file
63
src/socket.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { reactive } from "vue";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
const initial_state = {
|
||||||
|
notificationEvents: [],
|
||||||
|
notificationCounter: 0,
|
||||||
|
notificationAPICounter: 0,
|
||||||
|
progresses: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const state = reactive({ ...initial_state });
|
||||||
|
export const connectionState = reactive({
|
||||||
|
connected: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export function resetState() {
|
||||||
|
Object.assign(state, initial_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LIVE_LOG = 30
|
||||||
|
|
||||||
|
// "undefined" means the URL will be computed from the `window.location` object
|
||||||
|
// const URL = process.env.NODE_ENV === "production" ? undefined : "http://localhost:3000";
|
||||||
|
const URL = "http://localhost:3000";
|
||||||
|
|
||||||
|
export const socket = io(URL, {
|
||||||
|
autoConnect: true
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
connectionState.connected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
connectionState.connected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("notification", (message) => {
|
||||||
|
state.notificationCounter += 1
|
||||||
|
if (message.is_api_request) {
|
||||||
|
state.notificationAPICounter += 1
|
||||||
|
}
|
||||||
|
addLimited(state.notificationEvents, message, MAX_LIVE_LOG)
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("new_user", (new_user) => {
|
||||||
|
socket.emit("get_progress", (all_progress) => {
|
||||||
|
state.progresses = all_progress
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("refresh_score", (new_user) => {
|
||||||
|
socket.emit("get_progress", (all_progress) => {
|
||||||
|
state.progresses = all_progress
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
function addLimited(target, message, maxCount) {
|
||||||
|
target.unshift(message)
|
||||||
|
if (target.length > maxCount) {
|
||||||
|
target.length = maxCount
|
||||||
|
}
|
||||||
|
}
|
16
tailwind.config.js
Normal file
16
tailwind.config.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
transitionProperty: {
|
||||||
|
'width': 'width'
|
||||||
|
} ,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
darkMode: ['selector'],
|
||||||
|
}
|
1
utils.py
Normal file
1
utils.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
#!/usr/bin/env python3
|
16
vite.config.js
Normal file
16
vite.config.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
Loading…
Reference in a new issue