Skip to content

Commit 65e85fc

Browse files
Merge pull request #108 from CarterPerez-dev/project/ai-threat-detection
Project/ai threat detection phase 2-4
2 parents 7a65c5b + f9b5a61 commit 65e85fc

File tree

430 files changed

+18836
-1396
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

430 files changed

+18836
-1396
lines changed

PROJECTS/advanced/ai-threat-detection/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ API_KEY=changeme-generate-a-real-key
1313
POSTGRES_HOST_PORT=16969
1414
REDIS_HOST_PORT=26969
1515
BACKEND_HOST_PORT=36969
16+
FRONTEND_HOST_PORT=46969
1617

1718
# PostgreSQL
1819
POSTGRES_DB=angelusvigil

PROJECTS/advanced/ai-threat-detection/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# ©AngelaMos | 2026
22
# .gitignore
33

4-
# Planning docs
4+
# dev docs
55
.angelusvigil/
66

77
# Environment

PROJECTS/advanced/ai-threat-detection/backend/app/api/deps.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,3 @@ async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
1717
factory = request.app.state.session_factory
1818
async with factory() as session:
1919
yield session
20-
await session.commit()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
©AngelaMos | 2026
3+
ingest.py
4+
"""
5+
6+
import asyncio
7+
8+
from fastapi import APIRouter, Request
9+
from pydantic import BaseModel
10+
11+
router = APIRouter(prefix="/ingest", tags=["ingest"])
12+
13+
14+
class BatchIngestRequest(BaseModel):
15+
"""
16+
Payload for bulk log line ingestion
17+
"""
18+
19+
lines: list[str]
20+
21+
22+
@router.post("/batch", status_code=200)
23+
async def ingest_batch(
24+
body: BatchIngestRequest,
25+
request: Request,
26+
) -> dict[str, int]:
27+
"""
28+
Push a batch of raw log lines into the pipeline queue
29+
"""
30+
pipeline = getattr(request.app.state, "pipeline", None)
31+
if pipeline is None:
32+
return {"queued": 0}
33+
34+
queued = 0
35+
for line in body.lines:
36+
try:
37+
pipeline.raw_queue.put_nowait(line)
38+
queued += 1
39+
except asyncio.QueueFull:
40+
break
41+
42+
return {"queued": queued}

PROJECTS/advanced/ai-threat-detection/backend/app/api/models_api.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,60 @@
55

66
import uuid
77

8-
from fastapi import APIRouter
8+
from fastapi import APIRouter, Request
9+
from sqlalchemy import select
10+
from sqlalchemy.ext.asyncio import AsyncSession
11+
12+
from app.models.model_metadata import ModelMetadata
913

1014
router = APIRouter(prefix="/models", tags=["models"])
1115

1216

1317
@router.get("/status")
14-
async def model_status() -> dict[str, object]:
18+
async def model_status(request: Request, ) -> dict[str, object]:
1519
"""
16-
Return the status of active ML models.
20+
Return the status of active ML models
1721
"""
22+
models_loaded = getattr(request.app.state, "models_loaded", False)
23+
detection_mode = getattr(request.app.state, "detection_mode", "rules")
24+
25+
active_models: list[dict[str, object]] = []
26+
session_factory = getattr(request.app.state, "session_factory", None)
27+
if session_factory is not None:
28+
async with session_factory() as session:
29+
active_models = await _get_active_models(session)
30+
1831
return {
19-
"active_models": [],
20-
"detection_mode": "rules-only",
21-
"note": "ML models available after Phase 2 training",
32+
"models_loaded": models_loaded,
33+
"detection_mode": detection_mode,
34+
"active_models": active_models,
2235
}
2336

2437

2538
@router.post("/retrain", status_code=202)
2639
async def retrain() -> dict[str, object]:
2740
"""
28-
Trigger an async model retraining job.
41+
Trigger an async model retraining job
2942
"""
3043
return {
3144
"status": "accepted",
3245
"job_id": uuid.uuid4().hex,
33-
"note": "Retraining not available in Phase 1",
3446
}
47+
48+
49+
async def _get_active_models(
50+
session: AsyncSession, ) -> list[dict[str, object]]:
51+
"""
52+
Query all active model metadata records
53+
"""
54+
query = select(ModelMetadata).where(
55+
ModelMetadata.is_active == True # type: ignore[arg-type] # noqa: E712
56+
)
57+
rows = (await session.execute(query)).scalars().all()
58+
return [{
59+
"model_type": row.model_type,
60+
"version": row.version,
61+
"training_samples": row.training_samples,
62+
"metrics": row.metrics,
63+
"threshold": row.threshold,
64+
} for row in rows]

PROJECTS/advanced/ai-threat-detection/backend/app/api/stats.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515

1616
@router.get("", response_model=StatsResponse)
1717
async def get_stats(
18-
session: AsyncSession = Depends(get_session),
19-
time_range: str = Query("24h", alias="range"),
18+
session: AsyncSession = Depends(get_session),
19+
time_range: str = Query("24h", alias="range"),
2020
) -> StatsResponse:
2121
"""
2222
Aggregate threat statistics for a given time window.

PROJECTS/advanced/ai-threat-detection/backend/app/api/threats.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818

1919
@router.get("", response_model=ThreatListResponse)
2020
async def list_threats(
21-
session: AsyncSession = Depends(get_session),
22-
limit: int = Query(50, ge=1, le=100),
23-
offset: int = Query(0, ge=0),
24-
severity: str | None = Query(None),
25-
source_ip: str | None = Query(None),
26-
since: datetime | None = Query(None),
27-
until: datetime | None = Query(None),
21+
session: AsyncSession = Depends(get_session),
22+
limit: int = Query(50, ge=1, le=100),
23+
offset: int = Query(0, ge=0),
24+
severity: str | None = Query(None),
25+
source_ip: str | None = Query(None),
26+
since: datetime | None = Query(None),
27+
until: datetime | None = Query(None),
2828
) -> ThreatListResponse:
2929
"""
3030
List threat events with optional filters and pagination.
@@ -42,8 +42,8 @@ async def list_threats(
4242

4343
@router.get("/{threat_id}", response_model=ThreatEventResponse)
4444
async def get_threat(
45-
threat_id: uuid.UUID,
46-
session: AsyncSession = Depends(get_session),
45+
threat_id: uuid.UUID,
46+
session: AsyncSession = Depends(get_session),
4747
) -> ThreatEventResponse:
4848
"""
4949
Fetch a single threat event by ID.

PROJECTS/advanced/ai-threat-detection/backend/app/core/alerts/dispatcher.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
import logging
77

88
import redis.asyncio as aioredis
9-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
9+
from sqlalchemy.ext.asyncio import (
10+
AsyncSession,
11+
async_sessionmaker,
12+
)
1013

1114
from app.core.alerts import ALERTS_CHANNEL
15+
from app.core.detection.ensemble import classify_severity
1216
from app.core.ingestion.pipeline import ScoredRequest
1317
from app.schemas.websocket import WebSocketAlert
1418
from app.services.threat_service import create_threat_event
@@ -18,11 +22,12 @@
1822

1923
class AlertDispatcher:
2024
"""
21-
Routes scored threat events to storage, pub/sub, and structured logging.
25+
Routes scored threat events to storage, pub/sub,
26+
and structured logging
2227
23-
MEDIUM+ severity events are persisted to PostgreSQL and published
24-
to the Redis alerts channel for WebSocket relay.
25-
All events are logged to stdout.
28+
MEDIUM+ severity events are persisted to PostgreSQL
29+
and published to the Redis alerts channel for
30+
WebSocket relay. All events are logged to stdout.
2631
"""
2732

2833
def __init__(
@@ -35,39 +40,49 @@ def __init__(
3540

3641
async def dispatch(self, scored: ScoredRequest) -> None:
3742
"""
38-
Handle a scored request from the pipeline's dispatch stage.
43+
Handle a scored request from the pipeline's
44+
dispatch stage
3945
"""
46+
severity = classify_severity(scored.final_score)
47+
4048
logger.info(
41-
"threat_event severity=%s score=%.2f ip=%s path=%s rules=%s",
42-
scored.rule_result.severity,
43-
scored.rule_result.threat_score,
49+
"threat_event severity=%s score=%.2f mode=%s ip=%s path=%s rules=%s",
50+
severity,
51+
scored.final_score,
52+
scored.detection_mode,
4453
scored.entry.ip,
4554
scored.entry.path,
4655
scored.rule_result.matched_rules,
4756
)
4857

49-
if scored.rule_result.severity in ("HIGH", "MEDIUM"):
58+
if severity in ("HIGH", "MEDIUM"):
5059
await self._store_event(scored)
51-
await self._publish_alert(scored)
60+
await self._publish_alert(scored, severity)
5261

5362
async def _store_event(self, scored: ScoredRequest) -> None:
5463
"""
55-
Persist the scored request as a threat event in PostgreSQL.
64+
Persist the scored request as a threat event
65+
in PostgreSQL
5666
"""
5767
async with self._session_factory() as session:
5868
await create_threat_event(session, scored)
5969
await session.commit()
6070

61-
async def _publish_alert(self, scored: ScoredRequest) -> None:
71+
async def _publish_alert(
72+
self,
73+
scored: ScoredRequest,
74+
severity: str,
75+
) -> None:
6276
"""
63-
Publish a real-time alert to the Redis pub/sub channel.
77+
Publish a real-time alert to the Redis pub/sub
78+
channel
6479
"""
6580
alert = WebSocketAlert(
6681
timestamp=scored.entry.timestamp,
6782
source_ip=scored.entry.ip,
6883
request_path=scored.entry.path,
69-
threat_score=scored.rule_result.threat_score,
70-
severity=scored.rule_result.severity,
84+
threat_score=scored.final_score,
85+
severity=severity,
7186
component_scores=scored.rule_result.component_scores,
7287
)
7388
await self._redis.publish(ALERTS_CHANNEL, alert.model_dump_json())
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
©AngelaMos | 2026
3+
ensemble.py
4+
"""
5+
6+
7+
def normalize_ae_score(error: float, threshold: float) -> float:
8+
"""
9+
Normalize autoencoder reconstruction error to [0, 1]
10+
"""
11+
if threshold <= 0:
12+
return 0.0
13+
return min(error / (threshold * 2), 1.0)
14+
15+
16+
def normalize_if_score(raw_score: float) -> float:
17+
"""
18+
Normalize isolation forest score to [0, 1]
19+
20+
sklearn IF returns negative scores for anomalies,
21+
positive for normal samples
22+
"""
23+
return (1 - raw_score) / 2.0
24+
25+
26+
def fuse_scores(
27+
scores: dict[str, float],
28+
weights: dict[str, float],
29+
) -> float:
30+
"""
31+
Weighted average of per-model normalized scores
32+
"""
33+
total = 0.0
34+
weight_sum = 0.0
35+
for key, weight in weights.items():
36+
if key in scores:
37+
total += scores[key] * weight
38+
weight_sum += weight
39+
if weight_sum == 0:
40+
return 0.0
41+
return total / weight_sum
42+
43+
44+
def blend_scores(
45+
ml_score: float,
46+
rule_score: float,
47+
ml_weight: float = 0.7,
48+
) -> float:
49+
"""
50+
Blend ML ensemble score with rule engine score
51+
"""
52+
return min(
53+
ml_score * ml_weight + rule_score * (1.0 - ml_weight),
54+
1.0,
55+
)
56+
57+
58+
def classify_severity(score: float) -> str:
59+
"""
60+
Map a unified threat score to a severity label
61+
"""
62+
if score >= 0.7:
63+
return "HIGH"
64+
if score >= 0.5:
65+
return "MEDIUM"
66+
return "LOW"

0 commit comments

Comments
 (0)