fix: resolve 20 security vulnerabilities from comprehensive audit

Critical (1-3):
- Replace hardcoded admin credentials with secure auto-generation (seed.py)
- Enforce SECRET_KEY configuration, fail in production if missing (config.py)
- Add Zip Slip and Zip Bomb protection to all ZIP import services

High/Medium (4-9):
- Add 50MB file size limit and extension whitelist to evidence uploads
- Configure CORS origins via environment variable instead of hardcoded
- Migrate JWT storage from localStorage to HttpOnly cookies (frontend+backend)
- Add rate limiting (5/min) on login endpoint via slowapi
- Replace generic dict payloads with Pydantic schemas (mass assignment)

Medium (10-17):
- Check is_active on login to prevent disabled users from authenticating
- Sanitize exception messages in API responses (system, data_sources)
- Escape LIKE wildcards in all ilike search filters across 8 routers
- Run Docker container as non-root user (appuser)
- Make MINIO_SECURE configurable via environment variable
- Add password complexity policy (12+ chars, upper/lower/digit/special)
- Implement JWT token revocation via in-memory blacklist + reduce TTL to 15min
- Replace xml.etree with defusedxml to prevent Billion Laughs attacks

Low (18-20):
- Add security headers to Nginx (CSP, X-Frame-Options, HSTS-ready, etc.)
- Disable Swagger UI/ReDoc/OpenAPI in production
- Restrict /health endpoint to internal networks via Nginx ACL

Also: rewrite install.sh as interactive wizard for guided deployment,
fix test-from-template validation error (technique_id UUID vs MITRE ID)
This commit is contained in:
2026-02-11 08:56:26 +01:00
parent e7e63161e8
commit 64d64080e0
36 changed files with 1154 additions and 311 deletions

View File

@@ -1,24 +1,44 @@
# =============================================================================
# Aegis Environment Variables
# =============================================================================
# Copy this file to .env and fill in the values
# Copy this file to .env and fill in the values BEFORE deploying.
#
# Generate secure random values with:
# openssl rand -hex 32 (for SECRET_KEY)
# openssl rand -base64 18 (for passwords)
# =============================================================================
# ── Database ─────────────────────────────────────────────────────────────────
DB_USER=postgres
DB_PASSWORD=change-me-in-production
DB_PASSWORD= # REQUIRED — set a strong password
DB_NAME=attackdb
# ── Security ─────────────────────────────────────────────────────────────────
# IMPORTANT: Generate a strong random key for production
# Example: openssl rand -hex 32
SECRET_KEY=change-me-in-production-use-a-long-random-string
# REQUIRED in production — the app will refuse to start without it.
# Generate with: openssl rand -hex 32
SECRET_KEY=
TOKEN_EXPIRE_MINUTES=60
# ── Initial Admin Account ────────────────────────────────────────────────────
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
# printed to the backend container logs on first startup.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
# ── MinIO Object Storage ─────────────────────────────────────────────────────
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=change-me-in-production
MINIO_SECRET_KEY= # REQUIRED — set a strong password
MINIO_BUCKET=evidence
MINIO_SECURE=false # Set to true if MinIO is behind TLS
# ── CORS ──────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed frontend origins
CORS_ORIGINS=https://your-domain.com
# ── Frontend ─────────────────────────────────────────────────────────────────
FRONTEND_PORT=80
# ── Environment flag ─────────────────────────────────────────────────────────
# Set to "production" for production deployments (enforces SECRET_KEY, etc.)
AEGIS_ENV=production

View File

@@ -19,6 +19,12 @@ COPY . .
# Make entrypoints executable
RUN chmod +x /app/entrypoint.sh /app/entrypoint.prod.sh
# Create a non-root user and give it ownership of /app
RUN adduser --disabled-password --gecos '' --uid 1001 appuser \
&& chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8000

View File

@@ -4,10 +4,13 @@ Security utilities: password hashing and JWT token management.
This module provides pure functions for:
- Hashing and verifying passwords using bcrypt via passlib.
- Creating JWT access tokens using python-jose.
- Managing an in-memory token blacklist for revocation.
No endpoints are defined here.
"""
import threading
import uuid as _uuid
from datetime import datetime, timedelta, timezone
from jose import jwt
@@ -38,13 +41,53 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(data: dict) -> str:
"""Create a signed JWT containing *data* plus an ``exp`` claim.
"""Create a signed JWT containing *data* plus ``exp`` and ``jti`` claims.
The token expires after ``ACCESS_TOKEN_EXPIRE_MINUTES`` (from settings).
- ``jti`` (JWT ID): unique identifier that enables token revocation.
- ``exp``: expiration timestamp based on ``ACCESS_TOKEN_EXPIRE_MINUTES``.
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
to_encode.update({"exp": expire})
to_encode.update({
"exp": expire,
"jti": str(_uuid.uuid4()),
})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
# ---------------------------------------------------------------------------
# Token blacklist (in-memory)
# ---------------------------------------------------------------------------
# Stores (jti, expiry_timestamp) tuples. Entries are automatically purged
# once they are past their original expiry (the token would be invalid
# anyway at that point). Thread-safe via a simple lock.
#
# For multi-worker / multi-process deployments, consider replacing this
# with a shared store like Redis.
# ---------------------------------------------------------------------------
_blacklist: dict[str, float] = {} # jti → expiry epoch
_blacklist_lock = threading.Lock()
def blacklist_token(jti: str, exp: float) -> None:
"""Add *jti* to the blacklist until it naturally expires at *exp*."""
with _blacklist_lock:
_blacklist[jti] = exp
_cleanup_blacklist()
def is_token_blacklisted(jti: str) -> bool:
"""Return ``True`` if *jti* has been revoked."""
with _blacklist_lock:
return jti in _blacklist
def _cleanup_blacklist() -> None:
"""Remove entries whose tokens have already expired (caller holds lock)."""
now = datetime.now(timezone.utc).timestamp()
expired = [k for k, exp in _blacklist.items() if exp < now]
for k in expired:
del _blacklist[k]

View File

@@ -1,20 +1,46 @@
import os
import secrets
import warnings
from pydantic_settings import BaseSettings
# ---------------------------------------------------------------------------
# Detect environment: "production" when AEGIS_ENV or common indicators are set
# ---------------------------------------------------------------------------
_is_production = os.environ.get("AEGIS_ENV", "").lower() == "production" or bool(
os.environ.get("SECRET_KEY") # having an explicit SECRET_KEY hints prod
)
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql://postgres:postgres@postgres:5432/attackdb"
SECRET_KEY: str = "change-me-in-production"
# ── Security ──────────────────────────────────────────────────────
# SECRET_KEY has NO safe default. In development a random key is
# generated at startup (tokens invalidate on restart — acceptable
# for local dev). In production it MUST be supplied via env/.env
# so tokens survive restarts.
SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # short-lived for security; configurable via env
# ── CORS ─────────────────────────────────────────────────────────
# Comma-separated list of allowed origins, or a JSON array.
# In dev this defaults to common local ports; in production set it
# to the actual frontend domain(s).
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
# ── MinIO / S3 ───────────────────────────────────────────────────
MINIO_ENDPOINT: str = "minio:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "evidence"
MINIO_SECURE: bool = False # True → use HTTPS to connect to MinIO
# Re-testing
# ── Re-testing ───────────────────────────────────────────────────
MAX_RETEST_COUNT: int = 3 # maximum automatic retests per original test
# Scoring weights (must sum to 100)
# ── Scoring weights (must sum to 100) ────────────────────────────
SCORING_WEIGHT_TESTS: int = 40
SCORING_WEIGHT_DETECTION_RULES: int = 20
SCORING_WEIGHT_D3FEND: int = 15
@@ -26,3 +52,29 @@ class Settings(BaseSettings):
settings = Settings()
# ---------------------------------------------------------------------------
# Post-init validation for SECRET_KEY
# ---------------------------------------------------------------------------
_UNSAFE_SECRETS = {
"",
"change-me-in-production",
"change-me-in-production-use-a-long-random-string",
}
if settings.SECRET_KEY in _UNSAFE_SECRETS:
if _is_production:
raise RuntimeError(
"CRITICAL: SECRET_KEY is not configured. "
"Set a strong random value (>= 32 chars) via the SECRET_KEY "
"environment variable or in your .env file before running in "
"production. Example: openssl rand -hex 32"
)
# Development: auto-generate an ephemeral key and warn
settings.SECRET_KEY = secrets.token_hex(32)
warnings.warn(
"SECRET_KEY was not set — using an auto-generated ephemeral key. "
"JWT tokens will be invalidated on every restart. "
"Set SECRET_KEY in your environment for persistent sessions.",
stacklevel=2,
)

View File

@@ -2,25 +2,32 @@
Authentication and RBAC dependencies for FastAPI.
Provides:
- ``get_current_user``: decodes JWT, fetches user from DB, raises 401 on failure.
- ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or
Authorization header (fallback), fetches user from DB, raises 401 on failure.
- ``require_role``: factory that returns a dependency enforcing a specific role
(admins always pass).
"""
from fastapi import Depends, HTTPException, status
from typing import Optional
from fastapi import Cookie, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.auth import is_token_blacklisted
from app.config import settings
from app.database import get_db
from app.models.user import User
# ---------------------------------------------------------------------------
# OAuth2 scheme
# OAuth2 scheme (reads Authorization header — used as fallback / Swagger UI)
# ---------------------------------------------------------------------------
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
# Cookie name — must match the one set in the auth router
_COOKIE_NAME = "aegis_token"
# ---------------------------------------------------------------------------
# Current-user dependency
@@ -28,12 +35,19 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
aegis_token: Optional[str] = Cookie(None),
bearer_token: Optional[str] = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
"""Decode the JWT *token*, look up the user in *db*, and return it.
"""Decode the JWT, look up the user in *db*, and return it.
Token resolution order:
1. ``aegis_token`` **HttpOnly cookie** (preferred — immune to XSS).
2. ``Authorization: Bearer <token>`` header (fallback for API clients
and Swagger UI).
Raises :class:`~fastapi.HTTPException` **401** when:
- no token is found in either location,
- the token cannot be decoded,
- the ``sub`` claim is missing, or
- no matching active user exists in the database.
@@ -44,6 +58,11 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# Prefer cookie, fall back to header
token = aegis_token or bearer_token
if token is None:
raise credentials_exception
try:
payload = jwt.decode(
token,
@@ -53,11 +72,15 @@ async def get_current_user(
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
# Check token blacklist (revoked tokens)
jti: str | None = payload.get("jti")
if jti and is_token_blacklisted(jti):
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
if user is None or not user.is_active:
raise credentials_exception
return user

View File

@@ -1,10 +1,14 @@
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from sqlalchemy.exc import SQLAlchemyError
from app.routers import auth as auth_router
@@ -31,6 +35,9 @@ from app.routers import snapshots as snapshots_router
from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler
# ── Environment detection ─────────────────────────────────────────────────
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
# ── Logging ───────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
@@ -47,15 +54,33 @@ async def lifespan(app: FastAPI):
scheduler.shutdown(wait=False)
app = FastAPI(title="Attack Coverage Platform", lifespan=lifespan)
# ── In production, disable Swagger UI and ReDoc to hide API surface ──────
app = FastAPI(
title="Attack Coverage Platform",
lifespan=lifespan,
docs_url=None if _IS_PRODUCTION else "/docs",
redoc_url=None if _IS_PRODUCTION else "/redoc",
openapi_url=None if _IS_PRODUCTION else "/openapi.json",
)
# ── Rate Limiter ──────────────────────────────────────────────────────────
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# ── CORS ──────────────────────────────────────────────────────────────────
from app.config import settings as _settings
_cors_origins: list[str] = [
o.strip() for o in _settings.CORS_ORIGINS.split(",") if o.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"],
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
# ── Routers ──────────────────────────────────────────────────────────────
@@ -82,8 +107,13 @@ app.include_router(compliance_router.router, prefix="/api/v1")
app.include_router(snapshots_router.router, prefix="/api/v1")
@app.get("/health")
@app.get("/health", include_in_schema=False)
def health():
"""Minimal health check — returns only an HTTP 200 with no service metadata.
Access is restricted to internal networks at the Nginx level
(see ``frontend/nginx.conf``).
"""
return {"status": "ok"}

View File

@@ -1,17 +1,40 @@
"""Authentication router: login and current-user endpoints."""
"""Authentication router: login, logout and current-user endpoints.
from fastapi import APIRouter, Depends, HTTPException, status
The JWT access token is delivered as an **HttpOnly** cookie
(``aegis_token``) so it is inaccessible to client-side JavaScript,
mitigating XSS token-theft attacks. The JSON response also includes
the token in the body for backwards compatibility and for clients that
cannot use cookies (e.g. Swagger UI).
"""
import os
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.orm import Session
from app.auth import verify_password, create_access_token
from jose import jwt, JWTError
from app.auth import verify_password, create_access_token, blacklist_token
from app.config import settings
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut
# Rate limiter instance (shares backend state via app.state.limiter)
limiter = Limiter(key_func=get_remote_address)
router = APIRouter(prefix="/auth", tags=["auth"])
# Detect whether we're behind HTTPS (production) so the cookie can be Secure
_IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
# Cookie name used to transport the JWT
_COOKIE_NAME = "aegis_token"
# ---------------------------------------------------------------------------
# POST /auth/login
@@ -19,11 +42,19 @@ router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
@limiter.limit("5/minute")
def login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
"""Authenticate a user and return a JWT access token."""
"""Authenticate a user and return a JWT access token.
Rate-limited to **5 attempts per minute per IP** to prevent brute-force
attacks. The token is set as an HttpOnly cookie **and** returned in the
JSON body for API/Swagger compatibility.
"""
user = db.query(User).filter(User.username == form_data.username).first()
if user is None or not verify_password(form_data.password, user.hashed_password):
@@ -32,10 +63,70 @@ def login(
detail="Incorrect username or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled. Contact an administrator.",
)
access_token = create_access_token(data={"sub": user.username})
# Set HttpOnly cookie — inaccessible from JS
response.set_cookie(
key=_COOKIE_NAME,
value=access_token,
httponly=True,
secure=_IS_HTTPS,
samesite="strict",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
path="/",
)
return TokenResponse(access_token=access_token)
# ---------------------------------------------------------------------------
# POST /auth/logout
# ---------------------------------------------------------------------------
@router.post("/logout")
def logout(
request: Request,
response: Response,
aegis_token: str | None = Cookie(None),
):
"""Clear the authentication cookie and revoke the current token.
The token's ``jti`` is added to an in-memory blacklist so it cannot
be reused even if the cookie has already been copied elsewhere.
"""
# Attempt to blacklist the token's jti
token = aegis_token or request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
if token:
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
jti = payload.get("jti")
exp = payload.get("exp", 0)
if jti:
blacklist_token(jti, float(exp))
except JWTError:
pass # token already invalid — nothing to revoke
response.delete_cookie(
key=_COOKIE_NAME,
httponly=True,
secure=_IS_HTTPS,
samesite="strict",
path="/",
)
return {"detail": "Logged out"}
# ---------------------------------------------------------------------------
# GET /auth/me
# ---------------------------------------------------------------------------

View File

@@ -174,7 +174,8 @@ def list_campaigns(
if threat_actor_id:
query = query.filter(Campaign.threat_actor_id == threat_actor_id)
if search:
pattern = f"%{search}%"
from app.utils import escape_like
pattern = f"%{escape_like(search)}%"
query = query.filter(Campaign.name.ilike(pattern) | Campaign.description.ilike(pattern))
total = query.count()

View File

@@ -42,7 +42,8 @@ def list_defensive_techniques(
query = query.filter(DefensiveTechnique.tactic == tactic)
if search:
pattern = f"%{search}%"
from app.utils import escape_like
pattern = f"%{escape_like(search)}%"
query = query.filter(
DefensiveTechnique.name.ilike(pattern)
| DefensiveTechnique.d3fend_id.ilike(pattern)

View File

@@ -7,8 +7,10 @@ including sync triggers, enable/disable toggles, and statistics.
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
@@ -17,6 +19,17 @@ from app.models.user import User
from app.models.data_source import DataSource
from app.services.audit_service import log_action
# ---------------------------------------------------------------------------
# Pydantic schemas for request validation
# ---------------------------------------------------------------------------
class DataSourceUpdate(BaseModel):
"""Payload for updating a data source — only allowed fields."""
is_enabled: Optional[bool] = None
sync_frequency: Optional[str] = None
config: Optional[dict] = None
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/data-sources", tags=["data-sources"])
@@ -90,29 +103,26 @@ def list_data_sources(
@router.patch("/{source_id}")
def update_data_source(
source_id: str,
body: dict,
body: DataSourceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Update a data source (enable/disable, change config).
**Requires** the ``admin`` role.
Body fields (all optional):
- ``is_enabled`` (bool)
- ``sync_frequency`` (str)
- ``config`` (dict)
"""
ds = db.query(DataSource).filter(DataSource.id == source_id).first()
if not ds:
raise HTTPException(status_code=404, detail="Data source not found")
if "is_enabled" in body:
ds.is_enabled = bool(body["is_enabled"])
if "sync_frequency" in body:
ds.sync_frequency = body["sync_frequency"]
if "config" in body:
ds.config = body["config"]
update_data = body.model_dump(exclude_unset=True)
if "is_enabled" in update_data:
ds.is_enabled = update_data["is_enabled"]
if "sync_frequency" in update_data:
ds.sync_frequency = update_data["sync_frequency"]
if "config" in update_data:
ds.config = update_data["config"]
db.commit()
@@ -122,7 +132,7 @@ def update_data_source(
action="update_data_source",
entity_type="data_source",
entity_id=str(ds.id),
details={"updates": body},
details={"updates": update_data},
)
return {"message": "Data source updated", "id": str(ds.id)}
@@ -156,14 +166,14 @@ def sync_data_source(
try:
summary = handler(db)
except Exception as exc:
logger.error("Sync failed for %s: %s", ds.name, exc)
logger.error("Sync failed for %s: %s", ds.name, exc, exc_info=True)
ds.last_sync_status = "error"
ds.last_sync_at = datetime.utcnow()
ds.last_sync_stats = {"error": str(exc)}
db.commit()
raise HTTPException(
status_code=500,
detail=f"Sync failed: {str(exc)}",
detail=f"Sync failed for '{ds.display_name}'. Check server logs for details.",
)
# Update DS record (the handler may already have done this,
@@ -222,7 +232,7 @@ def sync_all_data_sources(
"stats": summary,
})
except Exception as exc:
logger.error("Sync failed for %s: %s", ds.name, exc)
logger.error("Sync failed for %s: %s", ds.name, exc, exc_info=True)
ds.last_sync_status = "error"
ds.last_sync_at = datetime.utcnow()
ds.last_sync_stats = {"error": str(exc)}
@@ -230,7 +240,7 @@ def sync_all_data_sources(
results.append({
"source": ds.name,
"status": "error",
"detail": str(exc),
"detail": "Sync failed. Check server logs for details.",
})
log_action(

View File

@@ -5,10 +5,12 @@ and managing the template ↔ detection rule associations.
"""
import logging
import uuid
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
@@ -20,6 +22,18 @@ from app.models.test_template import TestTemplate
from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
# ---------------------------------------------------------------------------
# Pydantic schemas for request validation
# ---------------------------------------------------------------------------
class DetectionRuleEvaluate(BaseModel):
"""Payload for evaluating a detection rule against a test."""
test_id: uuid.UUID
detection_rule_id: uuid.UUID
triggered: Optional[bool] = None
notes: Optional[str] = None
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detection-rules", tags=["detection-rules"])
@@ -53,7 +67,8 @@ def list_detection_rules(
query = query.filter(DetectionRule.severity == severity)
if search:
pattern = f"%{search}%"
from app.utils import escape_like
pattern = f"%{escape_like(search)}%"
query = query.filter(
DetectionRule.title.ilike(pattern)
| DetectionRule.description.ilike(pattern)
@@ -294,27 +309,15 @@ def get_detection_rules_for_test(
@router.post("/evaluate")
def evaluate_detection_rule(
payload: dict,
payload: DetectionRuleEvaluate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
):
"""Save or update the evaluation result for a detection rule on a test.
Body:
{
"test_id": "...",
"detection_rule_id": "...",
"triggered": true | false | null,
"notes": "optional notes"
}
"""
test_id = payload.get("test_id")
detection_rule_id = payload.get("detection_rule_id")
triggered = payload.get("triggered")
notes = payload.get("notes")
if not test_id or not detection_rule_id:
raise HTTPException(status_code=400, detail="test_id and detection_rule_id are required")
"""Save or update the evaluation result for a detection rule on a test."""
test_id = payload.test_id
detection_rule_id = payload.detection_rule_id
triggered = payload.triggered
notes = payload.notes
# Check test exists
from app.models.test import Test

View File

@@ -20,6 +20,7 @@ Access Control
"""
import hashlib
import os
import uuid as _uuid
from typing import Optional
@@ -43,6 +44,29 @@ _RED_EDITABLE_STATES = (TestState.draft, TestState.red_executing)
# States where blue evidence can be uploaded / deleted
_BLUE_EDITABLE_STATES = (TestState.blue_evaluating,)
# ---------------------------------------------------------------------------
# Upload safety limits
# ---------------------------------------------------------------------------
# Maximum upload size in bytes (default 50 MB)
_MAX_UPLOAD_SIZE = 50 * 1024 * 1024
# Allowed file extensions (lowercase, with leading dot)
_ALLOWED_EXTENSIONS: set[str] = {
# Images / screenshots
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg",
# Documents
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt",
".md", ".rtf", ".odt", ".ods",
# Logs & captures
".log", ".pcap", ".pcapng", ".evtx", ".json", ".xml",
".yaml", ".yml", ".toml",
# Archives (for bundled evidence)
".zip", ".tar", ".gz", ".7z",
# Other common evidence types
".har", ".eml", ".msg",
}
# ---------------------------------------------------------------------------
# Helpers
@@ -177,21 +201,39 @@ async def upload_evidence(
# Validate permissions
_validate_upload_permission(test, team, current_user)
# 1. Read content + hash
content = await file.read()
# 1. Validate file extension
file_name = file.filename or "unnamed"
_, ext = os.path.splitext(file_name)
if ext.lower() not in _ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type '{ext}' is not allowed. "
f"Permitted types: {', '.join(sorted(_ALLOWED_EXTENSIONS))}",
)
# 2. Read content with size limit
content = await file.read(_MAX_UPLOAD_SIZE + 1)
if len(content) > _MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum upload size of "
f"{_MAX_UPLOAD_SIZE // (1024 * 1024)} MB",
)
# 3. Hash
sha256 = hashlib.sha256(content).hexdigest()
# 2. Object key
file_name = file.filename or "unnamed"
key = f"{test_id}/{_uuid.uuid4()}_{file_name}"
# 4. Object key (sanitise filename to prevent path traversal in storage)
safe_name = os.path.basename(file_name)
key = f"{test_id}/{_uuid.uuid4()}_{safe_name}"
# 3. Upload to MinIO
# 5. Upload to MinIO
upload_file(content, key)
# 4. Persist metadata
# 6. Persist metadata
evidence = Evidence(
test_id=test_id,
file_name=file_name,
file_name=safe_name,
file_path=key,
sha256_hash=sha256,
uploaded_by=current_user.id,
@@ -202,7 +244,7 @@ async def upload_evidence(
db.commit()
db.refresh(evidence)
# 5. Audit
# 7. Audit
log_action(
db,
user_id=current_user.id,
@@ -210,7 +252,7 @@ async def upload_evidence(
entity_type="evidence",
entity_id=evidence.id,
details={
"file_name": file_name,
"file_name": safe_name,
"sha256": sha256,
"test_id": str(test_id),
"team": team.value,

View File

@@ -107,9 +107,10 @@ def _apply_filters(
query = query.filter(or_(*platform_filters))
if tactics:
from sqlalchemy import or_
from app.utils import escape_like
tactic_filters = []
for tactic in tactics:
tactic_filters.append(model.tactic.ilike(f"%{tactic}%"))
tactic_filters.append(model.tactic.ilike(f"%{escape_like(tactic)}%"))
query = query.filter(or_(*tactic_filters))
return query

View File

@@ -43,7 +43,8 @@ def coverage_summary(
"""Full coverage report as JSON — technique-by-technique with test counts."""
query = db.query(Technique)
if tactic:
query = query.filter(Technique.tactic.ilike(f"%{tactic}%"))
from app.utils import escape_like
query = query.filter(Technique.tactic.ilike(f"%{escape_like(tactic)}%"))
techniques = query.order_by(Technique.mitre_id).all()
@@ -109,7 +110,8 @@ def coverage_csv(
"""Export coverage as a downloadable CSV."""
query = db.query(Technique)
if tactic:
query = query.filter(Technique.tactic.ilike(f"%{tactic}%"))
from app.utils import escape_like
query = query.filter(Technique.tactic.ilike(f"%{escape_like(tactic)}%"))
techniques = query.order_by(Technique.mitre_id).all()

View File

@@ -80,10 +80,9 @@ def trigger_atomic_import(
try:
summary = import_atomic_red_team(db)
except Exception as exc:
logger.error("Atomic Red Team import failed: %s", exc)
logger.error("Atomic Red Team import failed: %s", exc, exc_info=True)
return {
"message": "Import failed",
"error": str(exc),
"message": "Import failed. Check server logs for details.",
}
return {

View File

@@ -69,13 +69,15 @@ def list_templates(
if source:
query = query.filter(TestTemplate.source == source)
if platform:
query = query.filter(TestTemplate.platform.ilike(f"%{platform}%"))
from app.utils import escape_like
query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%"))
if severity:
query = query.filter(TestTemplate.severity == severity)
if mitre_technique_id:
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
if search:
pattern = f"%{search}%"
from app.utils import escape_like
pattern = f"%{escape_like(search)}%"
query = query.filter(
or_(
TestTemplate.name.ilike(pattern),

View File

@@ -109,7 +109,8 @@ def list_tests(
if technique_id:
query = query.filter(Test.technique_id == technique_id)
if platform:
query = query.filter(Test.platform.ilike(f"%{platform}%"))
from app.utils import escape_like
query = query.filter(Test.platform.ilike(f"%{escape_like(platform)}%"))
if created_by:
query = query.filter(Test.created_by == created_by)
if pending_validation_side == "red":
@@ -200,15 +201,25 @@ def create_test_from_template(
detail=f"TestTemplate with id '{payload.template_id}' not found",
)
technique = db.query(Technique).filter(Technique.id == payload.technique_id).first()
# Resolve technique_id: accept both UUID and MITRE ID (e.g. "T1059.001")
technique = None
try:
technique_uuid = uuid.UUID(payload.technique_id)
technique = db.query(Technique).filter(Technique.id == technique_uuid).first()
except ValueError:
pass
if technique is None:
technique = db.query(Technique).filter(Technique.mitre_id == payload.technique_id).first()
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique with id '{payload.technique_id}' not found",
detail=f"Technique '{payload.technique_id}' not found",
)
test = Test(
technique_id=payload.technique_id,
technique_id=technique.id,
name=template.name,
description=template.description,
platform=template.platform,

View File

@@ -49,7 +49,8 @@ def list_threat_actors(
# Filters
if search:
pattern = f"%{search}%"
from app.utils import escape_like
pattern = f"%{escape_like(search)}%"
query = query.filter(
or_(
ThreatActor.name.ilike(pattern),
@@ -68,9 +69,10 @@ def list_threat_actors(
query = query.filter(ThreatActor.sophistication == sophistication)
if target_sectors:
from app.utils import escape_like
# JSONB contains check
query = query.filter(
func.cast(ThreatActor.target_sectors, func.text()).ilike(f"%{target_sectors}%")
func.cast(ThreatActor.target_sectors, func.text()).ilike(f"%{escape_like(target_sectors)}%")
)
# Total count

View File

@@ -75,4 +75,4 @@ class TestTemplateInstantiate(BaseModel):
"""Payload to create a real test from an existing template."""
template_id: uuid.UUID
technique_id: uuid.UUID
technique_id: str # accepts both UUID and MITRE ID (e.g. "T1059.001")

View File

@@ -1,9 +1,49 @@
"""Pydantic schemas for User management endpoints."""
import re
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
# ── Password policy ─────────────────────────────────────────────────
_MIN_PASSWORD_LENGTH = 12
_PASSWORD_RULES: list[tuple[str, str]] = [
(r"[A-Z]", "at least one uppercase letter"),
(r"[a-z]", "at least one lowercase letter"),
(r"[0-9]", "at least one digit"),
(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?`~]", "at least one special character"),
]
def _validate_password_strength(password: str) -> str:
"""Check that *password* satisfies the complexity policy.
Rules:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
"""
errors: list[str] = []
if len(password) < _MIN_PASSWORD_LENGTH:
errors.append(f"must be at least {_MIN_PASSWORD_LENGTH} characters long")
for pattern, description in _PASSWORD_RULES:
if not re.search(pattern, password):
errors.append(description)
if errors:
raise ValueError(
"Password does not meet complexity requirements: " + "; ".join(errors)
)
return password
# ── Create ──────────────────────────────────────────────────────────
@@ -16,6 +56,11 @@ class UserCreate(BaseModel):
password: str
role: str = "viewer"
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
return _validate_password_strength(v)
# ── Update ──────────────────────────────────────────────────────────
@@ -28,6 +73,13 @@ class UserUpdate(BaseModel):
is_active: bool | None = None
password: str | None = None
@field_validator("password")
@classmethod
def password_strength(cls, v: str | None) -> str | None:
if v is not None:
return _validate_password_strength(v)
return v
# ── Read (full) ─────────────────────────────────────────────────────

View File

@@ -1,32 +1,80 @@
"""
Seed script — creates the initial admin user if it does not already exist.
On first run the admin credentials are generated securely:
- Username is read from ``ADMIN_USERNAME`` env var (default: ``admin``).
- Password is read from ``ADMIN_PASSWORD`` env var. When the variable is
**not set**, a cryptographically random 16-character password is generated
automatically and printed to the startup logs so the operator can copy it.
Usage:
python -m app.seed
"""
import os
import secrets
import string
from app.auth import hash_password
from app.database import SessionLocal
from app.models.user import User
# Characters for auto-generated passwords (alphanumeric + safe symbols)
_PW_ALPHABET = string.ascii_letters + string.digits + "!@#$%&*-_+"
def _generate_password(length: int = 16) -> str:
"""Return a cryptographically random password of *length* characters."""
return "".join(secrets.choice(_PW_ALPHABET) for _ in range(length))
def seed_admin() -> None:
"""Create the default admin user when it is missing."""
"""Create the initial admin user when it is missing.
Reads ``ADMIN_USERNAME`` and ``ADMIN_PASSWORD`` from the environment.
If ``ADMIN_PASSWORD`` is empty or unset a secure random password is
generated and displayed in the logs.
"""
db = SessionLocal()
try:
existing = db.query(User).filter(User.username == "admin").first()
admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
existing = db.query(User).filter(User.username == admin_username).first()
if existing:
print("Admin user already exists — skipping.")
print(f"Admin user '{admin_username}' already exists — skipping.")
return
admin_password = os.environ.get("ADMIN_PASSWORD", "").strip()
password_was_generated = False
if not admin_password:
admin_password = _generate_password()
password_was_generated = True
admin = User(
username="admin",
hashed_password=hash_password("admin123"),
username=admin_username,
hashed_password=hash_password(admin_password),
role="admin",
)
db.add(admin)
db.commit()
print("Admin user created successfully.")
# ── Display credentials in startup logs ──────────────────────
print()
print("=" * 60)
print(" AEGIS — Initial Admin User Created")
print("=" * 60)
print(f" Username : {admin_username}")
if password_was_generated:
print(f" Password : {admin_password}")
print()
print(" ** This password was auto-generated because")
print(" ADMIN_PASSWORD was not set in the environment. **")
print(" ** Save it now — it will NOT be shown again. **")
else:
print(" Password : (set via ADMIN_PASSWORD env var)")
print("=" * 60)
print()
finally:
db.close()

View File

@@ -70,10 +70,50 @@ def _download_zip(url: str = ATOMIC_RT_ZIP_URL) -> bytes:
return content
def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
"""Extract *zip_bytes* into *dest* with Zip Slip and Zip Bomb protection.
Raises :class:`ValueError` if any member tries to escape the target
directory (path traversal / Zip Slip) or if the archive exceeds the
safety limits.
"""
# Maximum uncompressed size: 500 MB — prevents zip-bomb DoS
_MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024
# Maximum number of entries
_MAX_ENTRIES = 50_000
dest_path = Path(dest).resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
entries = zf.infolist()
if len(entries) > _MAX_ENTRIES:
raise ValueError(
f"ZIP archive contains {len(entries)} entries "
f"(limit: {_MAX_ENTRIES}) — possible zip bomb"
)
total_size = sum(info.file_size for info in entries)
if total_size > _MAX_UNCOMPRESSED_SIZE:
raise ValueError(
f"ZIP uncompressed size {total_size / (1024 * 1024):.0f} MB "
f"exceeds limit of {_MAX_UNCOMPRESSED_SIZE / (1024 * 1024):.0f} MB"
)
for member in entries:
target = (dest_path / member.filename).resolve()
if not target.is_relative_to(dest_path):
raise ValueError(
f"Zip Slip detected — member '{member.filename}' "
f"resolves outside target directory"
)
zf.extractall(dest)
def _extract_zip(zip_bytes: bytes, dest: str) -> Path:
"""Extract *zip_bytes* into *dest* and return the path to the atomics/ dir."""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
zf.extractall(dest)
_safe_extract_zip(zip_bytes, dest)
atomics_dir = Path(dest) / _ZIP_ROOT_PREFIX / "atomics"
if not atomics_dir.is_dir():
raise FileNotFoundError(

View File

@@ -75,10 +75,50 @@ def _download_zip(url: str = ELASTIC_ZIP_URL) -> bytes:
return content
def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
"""Extract *zip_bytes* into *dest* with Zip Slip and Zip Bomb protection.
Raises :class:`ValueError` if any member tries to escape the target
directory (path traversal / Zip Slip) or if the archive exceeds the
safety limits.
"""
# Maximum uncompressed size: 500 MB — prevents zip-bomb DoS
_MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024
# Maximum number of entries
_MAX_ENTRIES = 50_000
dest_path = Path(dest).resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
entries = zf.infolist()
if len(entries) > _MAX_ENTRIES:
raise ValueError(
f"ZIP archive contains {len(entries)} entries "
f"(limit: {_MAX_ENTRIES}) — possible zip bomb"
)
total_size = sum(info.file_size for info in entries)
if total_size > _MAX_UNCOMPRESSED_SIZE:
raise ValueError(
f"ZIP uncompressed size {total_size / (1024 * 1024):.0f} MB "
f"exceeds limit of {_MAX_UNCOMPRESSED_SIZE / (1024 * 1024):.0f} MB"
)
for member in entries:
target = (dest_path / member.filename).resolve()
if not target.is_relative_to(dest_path):
raise ValueError(
f"Zip Slip detected — member '{member.filename}' "
f"resolves outside target directory"
)
zf.extractall(dest)
def _extract_zip(zip_bytes: bytes, dest: str) -> Path:
"""Extract *zip_bytes* into *dest* and return rules/ dir."""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
zf.extractall(dest)
_safe_extract_zip(zip_bytes, dest)
rules_dir = Path(dest) / _ZIP_ROOT_PREFIX / "rules"
if not rules_dir.is_dir():
raise FileNotFoundError(

View File

@@ -11,7 +11,7 @@ parser. No LLMs or paid APIs are used.
import logging
import re
import xml.etree.ElementTree as ET
import defusedxml.ElementTree as ET
from datetime import datetime
import requests as _requests

View File

@@ -81,10 +81,50 @@ def _download_zip(url: str = SIGMA_ZIP_URL) -> bytes:
return content
def _safe_extract_zip(zip_bytes: bytes, dest: str) -> None:
"""Extract *zip_bytes* into *dest* with Zip Slip and Zip Bomb protection.
Raises :class:`ValueError` if any member tries to escape the target
directory (path traversal / Zip Slip) or if the archive exceeds the
safety limits.
"""
# Maximum uncompressed size: 500 MB — prevents zip-bomb DoS
_MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024
# Maximum number of entries
_MAX_ENTRIES = 50_000
dest_path = Path(dest).resolve()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
entries = zf.infolist()
if len(entries) > _MAX_ENTRIES:
raise ValueError(
f"ZIP archive contains {len(entries)} entries "
f"(limit: {_MAX_ENTRIES}) — possible zip bomb"
)
total_size = sum(info.file_size for info in entries)
if total_size > _MAX_UNCOMPRESSED_SIZE:
raise ValueError(
f"ZIP uncompressed size {total_size / (1024 * 1024):.0f} MB "
f"exceeds limit of {_MAX_UNCOMPRESSED_SIZE / (1024 * 1024):.0f} MB"
)
for member in entries:
target = (dest_path / member.filename).resolve()
if not target.is_relative_to(dest_path):
raise ValueError(
f"Zip Slip detected — member '{member.filename}' "
f"resolves outside target directory"
)
zf.extractall(dest)
def _extract_zip(zip_bytes: bytes, dest: str) -> Path:
"""Extract *zip_bytes* into *dest* and return the path to rules/ dir."""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
zf.extractall(dest)
_safe_extract_zip(zip_bytes, dest)
rules_dir = Path(dest) / _ZIP_ROOT_PREFIX / "rules"
if not rules_dir.is_dir():
raise FileNotFoundError(

View File

@@ -13,9 +13,11 @@ from app.config import settings
# Shared client (module-level singleton)
# ---------------------------------------------------------------------------
_scheme = "https" if settings.MINIO_SECURE else "http"
_client = boto3.client(
"s3",
endpoint_url=f"http://{settings.MINIO_ENDPOINT}",
endpoint_url=f"{_scheme}://{settings.MINIO_ENDPOINT}",
aws_access_key_id=settings.MINIO_ACCESS_KEY,
aws_secret_access_key=settings.MINIO_SECRET_KEY,
region_name="us-east-1", # MinIO ignores this but boto3 requires it

21
backend/app/utils.py Normal file
View File

@@ -0,0 +1,21 @@
"""Shared utility helpers."""
def escape_like(value: str) -> str:
"""Escape SQL LIKE wildcard characters (``%`` and ``_``).
Prevents user-supplied search terms from being interpreted as LIKE
pattern metacharacters when used with SQLAlchemy's ``ilike``/``like``
methods.
Usage::
from app.utils import escape_like
query.filter(Model.name.ilike(f"%{escape_like(term)}%"))
"""
return (
value
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)

View File

@@ -15,6 +15,8 @@ toml
taxii2-client
python-multipart
pydantic-settings
slowapi
defusedxml
# Testing
pytest

View File

@@ -62,7 +62,11 @@ services:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
MINIO_BUCKET: ${MINIO_BUCKET:-evidence}
MINIO_SECURE: "false"
MINIO_SECURE: ${MINIO_SECURE:-false}
CORS_ORIGINS: ${CORS_ORIGINS:-}
AEGIS_ENV: ${AEGIS_ENV:-production}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
depends_on:
postgres:
condition: service_healthy

View File

@@ -1,11 +1,9 @@
# =============================================================================
# Aegis - MITRE ATT&CK Coverage Platform
# Aegis - MITRE ATT&CK Coverage Platform (Development)
# =============================================================================
#
# Quick Start:
# docker-compose up -d
# docker-compose exec backend alembic upgrade head
# docker-compose exec backend python -m app.seed
#
# Access:
# - Frontend: http://localhost:5173
@@ -13,7 +11,8 @@
# - Swagger UI: http://localhost:8000/docs
# - MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
#
# Default credentials: admin / admin123
# Admin credentials are auto-generated on first start — check the
# backend container logs: docker-compose logs backend | grep -A5 "Admin"
# =============================================================================
services:
@@ -67,8 +66,9 @@ services:
environment:
# Database
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/attackdb
# Security (change in production!)
SECRET_KEY: change-me-in-production-use-a-long-random-string
# Security — SECRET_KEY is left unset so an ephemeral key is
# auto-generated for local development (see backend logs for warning).
# Set it explicitly if you need persistent sessions across restarts.
ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: 60
# MinIO

View File

@@ -4,22 +4,37 @@ server {
root /usr/share/nginx/html;
index index.html;
# Gzip compression
# ── Security Headers ─────────────────────────────────────────────
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-XSS-Protection "0" always;
# HSTS — uncomment when using HTTPS (recommended in production)
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# CSP: allow self + inline styles (React build) + data: URIs for fonts/images
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Hide Nginx version
server_tokens off;
# ── Gzip compression ─────────────────────────────────────────────
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# SPA routing - serve index.html for all routes
# ── SPA routing ──────────────────────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
# ── Cache static assets ──────────────────────────────────────────
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy API requests to backend (for production)
# ── Proxy API requests to backend ────────────────────────────────
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
@@ -33,8 +48,15 @@ server {
proxy_send_timeout 300s;
}
# Health endpoint proxy
# ── Health endpoint proxy (internal only) ────────────────────────
location /health {
# Only allow health checks from Docker internal network and localhost
allow 127.0.0.1;
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
deny all;
proxy_pass http://backend:8000/health;
}
}

View File

@@ -1,25 +1,32 @@
import client from "./client";
import type { User } from "../types/models";
interface TokenResponse {
access_token: string;
token_type: string;
}
/** Authenticate and return the access token. */
/**
* Authenticate the user.
*
* The backend sets an HttpOnly cookie with the JWT — no token is stored
* in JavaScript memory or localStorage.
*/
export async function login(
username: string,
password: string,
): Promise<string> {
): Promise<void> {
const params = new URLSearchParams();
params.append("username", username);
params.append("password", password);
const { data } = await client.post<TokenResponse>("/auth/login", params, {
await client.post("/auth/login", params, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
}
return data.access_token;
/** Clear the authentication cookie on the server. */
export async function logout(): Promise<void> {
try {
await client.post("/auth/logout");
} catch {
// Best-effort — the cookie will expire anyway
}
}
/** Fetch the currently authenticated user profile. */

View File

@@ -5,15 +5,8 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
const client = axios.create({
baseURL: API_BASE_URL,
headers: { "Content-Type": "application/json" },
});
// Attach the JWT token on every request (if present)
client.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
// Send the HttpOnly cookie automatically on every request
withCredentials: true,
});
// Response interceptor for error handling
@@ -22,9 +15,8 @@ client.interceptors.response.use(
(error: AxiosError<{ detail?: string; code?: string }>) => {
const status = error.response?.status;
// On 401, clear token and redirect to login
// On 401, redirect to login (cookie is managed by the browser)
if (status === 401) {
localStorage.removeItem("token");
// Only redirect if not already on login page
if (window.location.pathname !== "/login") {
window.location.href = "/login";

View File

@@ -93,7 +93,7 @@ export default function EvidenceUpload({ onUpload, isUploading }: EvidenceUpload
)}
</p>
<p className="mt-1 text-xs text-gray-500">
Screenshots, logs, pcap files, etc.
Screenshots, logs, pcap files, etc. (max 50 MB)
</p>
</div>

View File

@@ -6,7 +6,11 @@ import {
useCallback,
type ReactNode,
} from "react";
import { login as apiLogin, getMe } from "../api/auth";
import {
login as apiLogin,
logout as apiLogout,
getMe,
} from "../api/auth";
import type { User } from "../types/models";
/* ── Context shape ────────────────────────────────────────────────── */
@@ -27,29 +31,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// On mount — check for a persisted token and hydrate the user
// On mount — try to hydrate the user from the existing HttpOnly cookie.
// If no valid cookie exists the /auth/me call will 401 and we stay
// unauthenticated — no localStorage involved.
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
setIsLoading(false);
return;
}
getMe()
.then(setUser)
.catch(() => localStorage.removeItem("token"))
.catch(() => setUser(null))
.finally(() => setIsLoading(false));
}, []);
const login = useCallback(async (username: string, password: string) => {
const token = await apiLogin(username, password);
localStorage.setItem("token", token);
// The backend sets the HttpOnly cookie automatically
await apiLogin(username, password);
const me = await getMe();
setUser(me);
}, []);
const logout = useCallback(() => {
localStorage.removeItem("token");
const logout = useCallback(async () => {
await apiLogout();
setUser(null);
}, []);

View File

@@ -1,8 +1,9 @@
#!/bin/bash
# =============================================================================
# Aegis - Production Installation Script
# Aegis - Interactive Production Installer
# =============================================================================
# This script sets up the Aegis platform for production.
# Sets up the Aegis platform for production with an interactive wizard
# that configures all environment variables.
#
# Usage:
# chmod +x scripts/install.sh
@@ -10,7 +11,7 @@
#
# Prerequisites:
# - Docker and Docker Compose installed
# - Port 80 (or FRONTEND_PORT) available
# - Port 80 (or chosen port) available
# =============================================================================
set -e
@@ -20,33 +21,58 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
# Colors
# ── Colors & helpers ──────────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
print_ok() { echo -e "${GREEN}[]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
print_error() { echo -e "${RED}[]${NC} $1"; }
print_info() { echo -e "${CYAN}[i]${NC} $1"; }
print_header() { echo -e "\n${BOLD}── $1 ──${NC}"; }
print_ok() { echo -e "${GREEN}[OK]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
print_error() { echo -e "${RED}[X]${NC} $1"; }
print_info() { echo -e "${CYAN}[i]${NC} $1"; }
print_header() { echo -e "\n${BOLD}── $1 ──${NC}\n"; }
print_prompt() { echo -en "${CYAN}>>>${NC} $1"; }
# Generate a cryptographically secure random string
gen_secret() {
python3 -c "import secrets; print(secrets.token_hex($1))" 2>/dev/null \
|| openssl rand -hex "$1" 2>/dev/null \
|| head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
}
gen_password() {
python3 -c "import secrets; print(secrets.token_urlsafe($1))" 2>/dev/null \
|| openssl rand -base64 "$1" 2>/dev/null \
|| head -c "$1" /dev/urandom | base64 | tr -d '=/+' | head -c "$1"
}
# ── Banner ────────────────────────────────────────────────────────────────
clear 2>/dev/null || true
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo " Aegis - Production Installation ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
echo -e "${BOLD}"
echo " ╔══════════════════════════════════════════════════════════╗"
echo " ║ ║"
echo " ║ Aegis - Installation Wizard ║"
echo " ║ MITRE ATT&CK Coverage Platform ║"
echo " ║ ║"
echo " ╚══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
# ── 1. Check prerequisites ──────────────────────────────────────────
# ═════════════════════════════════════════════════════════════════════════
# STEP 1: Check prerequisites
# ═════════════════════════════════════════════════════════════════════════
print_header "Checking prerequisites"
print_header "Step 1/5 - Checking prerequisites"
if ! command -v docker &> /dev/null; then
print_error "Docker is not installed. Please install Docker first."
echo " -> https://docs.docker.com/engine/install/"
echo " https://docs.docker.com/engine/install/"
exit 1
fi
print_ok "Docker found: $(docker --version | head -1)"
@@ -57,236 +83,444 @@ if ! docker info > /dev/null 2>&1; then
fi
print_ok "Docker daemon is running"
# Check for docker compose (v2 plugin or standalone)
if docker compose version > /dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
COMPOSE_CMD="docker-compose"
else
print_error "Docker Compose is not installed."
echo " -> https://docs.docker.com/compose/install/"
echo " https://docs.docker.com/compose/install/"
exit 1
fi
print_ok "Docker Compose found ($COMPOSE_CMD)"
# Auto-detect Docker API version to avoid client/server mismatch
# Auto-detect Docker API version
DOCKER_SERVER_API=$(docker version --format '{{.Server.APIVersion}}' 2>/dev/null || echo "")
if [ -n "$DOCKER_SERVER_API" ]; then
export DOCKER_API_VERSION="$DOCKER_SERVER_API"
print_info "Docker API version: $DOCKER_SERVER_API"
fi
# ── 2. Setup .env file ──────────────────────────────────────────────
# ═════════════════════════════════════════════════════════════════════════
# STEP 2: Interactive configuration
# ═════════════════════════════════════════════════════════════════════════
print_header "Environment configuration"
print_header "Step 2/5 - Configuration"
ENV_FILE=".env"
SKIP_CONFIG=false
if [ -f "$ENV_FILE" ]; then
print_warn ".env file already exists"
read -p " Overwrite with new values? (y/N) " -n 1 -r
print_warn "An existing .env file was found."
echo ""
print_prompt "Do you want to reconfigure? (y/N): "
read -r REPLY
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Keeping existing .env file"
SKIP_ENV=true
print_info "Keeping existing configuration."
SKIP_CONFIG=true
fi
fi
if [ "${SKIP_ENV}" != "true" ]; then
# Generate secure secrets
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n')
DB_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))" 2>/dev/null || openssl rand -base64 24 2>/dev/null || head -c 24 /dev/urandom | base64)
MINIO_SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))" 2>/dev/null || openssl rand -base64 24 2>/dev/null || head -c 24 /dev/urandom | base64)
if [ "$SKIP_CONFIG" = false ]; then
cat > "$ENV_FILE" <<EOF
# ── Aegis Production Environment ─────────────────────────────────
echo -e " ${DIM}Answer the following questions to configure Aegis."
echo -e " Press Enter to accept the default value shown in [brackets].${NC}"
echo ""
# ── Domain / URL ──────────────────────────────────────────────────
echo -e " ${BOLD}1. Domain Configuration${NC}"
echo -e " ${DIM}The domain where Aegis will be accessible."
echo -e " Examples: aegis.example.com, 192.168.1.100, localhost${NC}"
echo ""
print_prompt "Domain or IP [localhost]: "
read -r INPUT_DOMAIN
DOMAIN="${INPUT_DOMAIN:-localhost}"
# ── Protocol ──────────────────────────────────────────────────────
if [ "$DOMAIN" = "localhost" ] || [ "$DOMAIN" = "127.0.0.1" ]; then
PROTOCOL="http"
print_info "Using HTTP for local deployment"
else
echo ""
print_prompt "Are you using HTTPS/SSL? (Y/n): "
read -r REPLY
if [[ $REPLY =~ ^[Nn]$ ]]; then
PROTOCOL="http"
else
PROTOCOL="https"
fi
fi
# ── Port ──────────────────────────────────────────────────────────
if [ "$PROTOCOL" = "https" ]; then
DEFAULT_PORT=443
else
DEFAULT_PORT=80
fi
echo ""
echo -e " ${BOLD}2. Port${NC}"
print_prompt "Frontend port [$DEFAULT_PORT]: "
read -r INPUT_PORT
FRONTEND_PORT="${INPUT_PORT:-$DEFAULT_PORT}"
# Build the full origin URL for CORS
if { [ "$PROTOCOL" = "https" ] && [ "$FRONTEND_PORT" = "443" ]; } || \
{ [ "$PROTOCOL" = "http" ] && [ "$FRONTEND_PORT" = "80" ]; }; then
ORIGIN_URL="${PROTOCOL}://${DOMAIN}"
else
ORIGIN_URL="${PROTOCOL}://${DOMAIN}:${FRONTEND_PORT}"
fi
# ── Admin account ─────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}3. Admin Account${NC}"
echo -e " ${DIM}The initial administrator account for Aegis.${NC}"
echo ""
print_prompt "Admin username [admin]: "
read -r INPUT_ADMIN_USER
ADMIN_USERNAME="${INPUT_ADMIN_USER:-admin}"
echo ""
echo -e " ${DIM}Leave empty to auto-generate a secure password."
echo -e " The password will be shown in the installation summary.${NC}"
print_prompt "Admin password [auto-generate]: "
read -rs INPUT_ADMIN_PASS
echo ""
ADMIN_PASSWORD="${INPUT_ADMIN_PASS}"
if [ -z "$ADMIN_PASSWORD" ]; then
ADMIN_PASSWORD=$(gen_password 18)
ADMIN_PW_GENERATED=true
print_info "Password will be auto-generated"
else
ADMIN_PW_GENERATED=false
print_ok "Password set"
fi
# ── Database ──────────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}4. Database${NC}"
print_prompt "Database name [attackdb]: "
read -r INPUT_DB_NAME
DB_NAME="${INPUT_DB_NAME:-attackdb}"
print_prompt "Database user [postgres]: "
read -r INPUT_DB_USER
DB_USER="${INPUT_DB_USER:-postgres}"
echo -e " ${DIM}Leave empty to auto-generate a secure password.${NC}"
print_prompt "Database password [auto-generate]: "
read -rs INPUT_DB_PASS
echo ""
if [ -z "$INPUT_DB_PASS" ]; then
DB_PASSWORD=$(gen_password 24)
print_info "Database password auto-generated"
else
DB_PASSWORD="$INPUT_DB_PASS"
print_ok "Database password set"
fi
# ── Token expiry ──────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}5. Session Duration${NC}"
print_prompt "Token expiry in minutes [60]: "
read -r INPUT_TOKEN_EXP
TOKEN_EXPIRE_MINUTES="${INPUT_TOKEN_EXP:-60}"
# ── MITRE sync ────────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}6. Initial Data${NC}"
print_prompt "Run MITRE ATT&CK sync after install? (Y/n): "
read -r INPUT_SYNC
if [[ $INPUT_SYNC =~ ^[Nn]$ ]]; then
RUN_MITRE_SYNC=false
else
RUN_MITRE_SYNC=true
fi
# ── Generate secrets ──────────────────────────────────────────────
SECRET_KEY=$(gen_secret 32)
MINIO_SECRET=$(gen_password 24)
# ── Show summary before writing ──────────────────────────────────
echo ""
echo -e "${BOLD} ┌──────────────────────────────────────────────────────┐${NC}"
echo -e "${BOLD} │ Configuration Summary │${NC}"
echo -e "${BOLD} ├──────────────────────────────────────────────────────┤${NC}"
echo -e " │ URL: ${CYAN}${ORIGIN_URL}${NC}"
echo -e " │ Admin user: ${CYAN}${ADMIN_USERNAME}${NC}"
if [ "$ADMIN_PW_GENERATED" = true ]; then
echo -e " │ Admin pass: ${CYAN}(auto-generated)${NC}"
else
echo -e " │ Admin pass: ${CYAN}(custom)${NC}"
fi
echo -e " │ Database: ${CYAN}${DB_USER}@${DB_NAME}${NC}"
echo -e " │ Port: ${CYAN}${FRONTEND_PORT}${NC}"
echo -e " │ Session TTL: ${CYAN}${TOKEN_EXPIRE_MINUTES} min${NC}"
echo -e " │ MITRE sync: ${CYAN}$([ "$RUN_MITRE_SYNC" = true ] && echo "yes" || echo "no")${NC}"
echo -e "${BOLD} └──────────────────────────────────────────────────────┘${NC}"
echo ""
print_prompt "Proceed with these settings? (Y/n): "
read -r CONFIRM
if [[ $CONFIRM =~ ^[Nn]$ ]]; then
print_warn "Installation cancelled. Run the script again to reconfigure."
exit 0
fi
# ── Write .env ────────────────────────────────────────────────────
cat > "$ENV_FILE" <<ENVEOF
# =============================================================================
# Aegis Production Environment
# Generated by install.sh on $(date -u +"%Y-%m-%d %H:%M:%S UTC")
# =============================================================================
# Database
DB_USER=postgres
# ── Database ─────────────────────────────────────────────────────────────────
DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
DB_NAME=attackdb
DB_NAME=${DB_NAME}
# Security
# ── Security ─────────────────────────────────────────────────────────────────
SECRET_KEY=${SECRET_KEY}
TOKEN_EXPIRE_MINUTES=60
TOKEN_EXPIRE_MINUTES=${TOKEN_EXPIRE_MINUTES}
# MinIO Object Storage
# ── Initial Admin Account ────────────────────────────────────────────────────
ADMIN_USERNAME=${ADMIN_USERNAME}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
# ── MinIO Object Storage ─────────────────────────────────────────────────────
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=${MINIO_SECRET}
MINIO_BUCKET=evidence
# Frontend
FRONTEND_PORT=80
EOF
# ── CORS (allowed frontend origins) ─────────────────────────────────────────
CORS_ORIGINS=${ORIGIN_URL}
print_ok ".env file created with secure random secrets"
print_info "Review and edit .env if needed before proceeding"
fi
# ── Frontend ─────────────────────────────────────────────────────────────────
FRONTEND_PORT=${FRONTEND_PORT}
# ── 3. Build and start containers ────────────────────────────────────
# ── Environment ──────────────────────────────────────────────────────────────
AEGIS_ENV=production
ENVEOF
print_header "Building and starting containers"
print_ok ".env file created with secure configuration"
print_info "This may take a few minutes on first run..."
print_info "Project root: $PROJECT_ROOT"
fi # end SKIP_CONFIG
if ! $COMPOSE_CMD -f docker-compose.prod.yml up -d --build; then
# ═════════════════════════════════════════════════════════════════════════
# STEP 3: Build and start
# ═════════════════════════════════════════════════════════════════════════
print_header "Step 3/5 - Building and starting containers"
print_info "This may take several minutes on first run..."
if ! $COMPOSE_CMD -f docker-compose.prod.yml up -d --build 2>&1; then
print_error "Failed to build/start containers. Check the output above."
exit 1
fi
print_ok "Containers started"
# ── 4. Wait for services to be healthy ───────────────────────────────
# ═════════════════════════════════════════════════════════════════════════
# STEP 4: Wait for services
# ═════════════════════════════════════════════════════════════════════════
print_header "Waiting for services"
print_header "Step 4/5 - Waiting for services to be ready"
# Wait for postgres
print_info "Waiting for PostgreSQL..."
# Wait for PostgreSQL
echo -en " PostgreSQL ..."
MAX_RETRIES=30
RETRY=0
until docker exec aegis-postgres pg_isready -U postgres > /dev/null 2>&1; do
RETRY=$((RETRY + 1))
if [ $RETRY -ge $MAX_RETRIES ]; then
print_error "PostgreSQL failed to start after $MAX_RETRIES attempts"
echo " Check logs: docker logs aegis-postgres"
echo ""
print_error "PostgreSQL failed to start. Check: docker logs aegis-postgres"
exit 1
fi
echo -n "."
sleep 2
done
print_ok "PostgreSQL is ready"
echo -e " ${GREEN}ready${NC}"
# Wait for backend (which runs migrations + seed on startup)
print_info "Waiting for backend (running migrations and seeds)..."
# Wait for backend (runs migrations + seed)
echo -en " Backend (migrations + seed) ..."
RETRY=0
until docker exec aegis-backend curl -sf http://localhost:8000/health > /dev/null 2>&1; do
RETRY=$((RETRY + 1))
if [ $RETRY -ge 90 ]; then
print_error "Backend failed to start after 180 seconds"
echo " Check logs: docker logs aegis-backend"
echo ""
print_error "Backend failed to start after 3 minutes."
echo " Check: docker logs aegis-backend"
exit 1
fi
# Show progress every 10 attempts
if [ $((RETRY % 5)) -eq 0 ]; then
print_info " Still waiting... ($RETRY attempts, checking logs)"
docker logs aegis-backend --tail 3 2>/dev/null | while IFS= read -r line; do echo " $line"; done
fi
echo -n "."
sleep 2
done
print_ok "Backend is ready (migrations and seeds completed)"
echo -e " ${GREEN}ready${NC}"
# Wait for frontend
print_info "Waiting for frontend..."
RETRY=0
FRONTEND_PORT=$(grep FRONTEND_PORT "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "80")
FRONTEND_PORT=${FRONTEND_PORT:-80}
echo -en " Frontend ..."
RETRY=0
until curl -sf "http://localhost:${FRONTEND_PORT}" > /dev/null 2>&1; do
RETRY=$((RETRY + 1))
if [ $RETRY -ge 30 ]; then
print_error "Frontend failed to start after 60 seconds"
echo " Check logs: docker logs aegis-frontend"
echo ""
print_error "Frontend failed to start. Check: docker logs aegis-frontend"
exit 1
fi
echo -n "."
sleep 2
done
print_ok "Frontend is ready"
echo -e " ${GREEN}ready${NC}"
# ── 5. Trigger MITRE ATT&CK sync ────────────────────────────────────
print_ok "All services are running"
print_header "Initial data sync"
# ── Extract admin credentials from backend logs ──────────────────────
echo ""
read -p "Run initial MITRE ATT&CK sync? This imports ~700 techniques. (Y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
print_info "Authenticating..."
ADMIN_CREDS_USER=""
ADMIN_CREDS_PASS=""
# Get admin token (try via nginx first, then directly to backend container)
API_URL="http://localhost:${FRONTEND_PORT}/api/v1"
TOKEN=$(curl -sf --max-time 10 -X POST "${API_URL}/auth/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin123" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
# Try to extract the credentials from the backend startup logs
LOG_OUTPUT=$(docker logs aegis-backend 2>&1 | tail -20)
# Fallback: try directly via backend container
if [ -z "$TOKEN" ] || [ "$TOKEN" = "" ]; then
TOKEN=$(docker exec aegis-backend curl -sf -X POST "http://localhost:8000/api/v1/auth/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin123" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
API_URL="http://localhost:8000/api/v1"
API_VIA_DOCKER=true
fi
if [ -n "$TOKEN" ] && [ "$TOKEN" != "" ]; then
print_info "Syncing MITRE ATT&CK data (this takes 1-2 minutes)..."
if [ "$API_VIA_DOCKER" = true ]; then
SYNC_RESULT=$(docker exec aegis-backend curl -sf --max-time 300 -X POST "${API_URL}/system/sync-mitre" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "error")
else
SYNC_RESULT=$(curl -sf --max-time 300 -X POST "${API_URL}/system/sync-mitre" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "error")
fi
if [ "$SYNC_RESULT" != "error" ]; then
print_ok "MITRE ATT&CK sync completed"
else
print_warn "MITRE sync may have timed out. Check the System page in the UI."
fi
# Sync data sources
print_info "Syncing data sources (Atomic Red Team, SigmaHQ, etc.)..."
if [ "$API_VIA_DOCKER" = true ]; then
CURL_PREFIX="docker exec aegis-backend curl"
else
CURL_PREFIX="curl"
fi
for source_id in $($CURL_PREFIX -sf "${API_URL}/data-sources" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null | \
python3 -c "import sys,json; [print(s['id']) for s in json.load(sys.stdin)]" 2>/dev/null); do
$CURL_PREFIX -sf --max-time 120 -X POST "${API_URL}/data-sources/${source_id}/sync" \
-H "Authorization: Bearer $TOKEN" > /dev/null 2>&1 || true
done
print_ok "Data source sync triggered"
else
print_warn "Could not authenticate. Run MITRE sync manually from the System page."
print_info "Default credentials: admin / admin123"
fi
else
print_info "Skipping MITRE sync. You can do this later from the System page."
if echo "$LOG_OUTPUT" | grep -q "Initial Admin User Created"; then
ADMIN_CREDS_USER=$(echo "$LOG_OUTPUT" | grep "Username :" | sed 's/.*Username : //')
ADMIN_CREDS_PASS=$(echo "$LOG_OUTPUT" | grep "Password :" | sed 's/.*Password : //')
fi
# ── 6. Summary ───────────────────────────────────────────────────────
# Fallback: if we set it via env, use those values
if [ -z "$ADMIN_CREDS_USER" ]; then
ADMIN_CREDS_USER=$(grep ADMIN_USERNAME "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "admin")
ADMIN_CREDS_USER="${ADMIN_CREDS_USER:-admin}"
fi
# Get the server's IP
if [ -z "$ADMIN_CREDS_PASS" ] || [ "$ADMIN_CREDS_PASS" = "(set via ADMIN_PASSWORD env var)" ]; then
ADMIN_CREDS_PASS=$(grep ADMIN_PASSWORD "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "")
fi
# ═════════════════════════════════════════════════════════════════════════
# STEP 5: Initial data sync (optional)
# ═════════════════════════════════════════════════════════════════════════
# Re-read RUN_MITRE_SYNC if we skipped config
if [ "$SKIP_CONFIG" = true ]; then
echo ""
print_prompt "Run initial MITRE ATT&CK sync? (~700 techniques, 1-2 min) (Y/n): "
read -r INPUT_SYNC
if [[ $INPUT_SYNC =~ ^[Nn]$ ]]; then
RUN_MITRE_SYNC=false
else
RUN_MITRE_SYNC=true
fi
fi
if [ "$RUN_MITRE_SYNC" = true ]; then
print_header "Step 5/5 - Initial data sync"
print_info "Authenticating with backend..."
# Authenticate via backend container (most reliable)
TOKEN=$(docker exec aegis-backend curl -sf -X POST "http://localhost:8000/api/v1/auth/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${ADMIN_CREDS_USER}&password=${ADMIN_CREDS_PASS}" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
if [ -n "$TOKEN" ] && [ "$TOKEN" != "" ]; then
# MITRE ATT&CK sync
print_info "Syncing MITRE ATT&CK techniques (1-2 minutes)..."
SYNC_RESULT=$(docker exec aegis-backend curl -sf --max-time 300 \
-X POST "http://localhost:8000/api/v1/system/sync-mitre" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "error")
if [ "$SYNC_RESULT" != "error" ]; then
NEW_TECHNIQUES=$(echo "$SYNC_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('new',0))" 2>/dev/null || echo "?")
print_ok "MITRE sync completed ($NEW_TECHNIQUES techniques imported)"
else
print_warn "MITRE sync timed out. You can retry from the System page."
fi
# Data sources sync
print_info "Syncing data sources (Atomic Red Team, SigmaHQ, etc.)..."
SOURCES=$(docker exec aegis-backend curl -sf "http://localhost:8000/api/v1/data-sources" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null | \
python3 -c "import sys,json; [print(s['id']) for s in json.load(sys.stdin)]" 2>/dev/null || echo "")
SYNC_COUNT=0
for source_id in $SOURCES; do
docker exec aegis-backend curl -sf --max-time 120 \
-X POST "http://localhost:8000/api/v1/data-sources/${source_id}/sync" \
-H "Authorization: Bearer $TOKEN" > /dev/null 2>&1 && SYNC_COUNT=$((SYNC_COUNT + 1)) || true
done
if [ "$SYNC_COUNT" -gt 0 ]; then
print_ok "Data sources synced ($SYNC_COUNT sources)"
fi
else
print_warn "Could not authenticate. Run MITRE sync from the System page."
fi
else
print_header "Step 5/5 - Skipping data sync"
print_info "You can import data later from the System page."
fi
# ═════════════════════════════════════════════════════════════════════════
# FINAL SUMMARY
# ═════════════════════════════════════════════════════════════════════════
# Build the access URL
ORIGIN_URL=$(grep CORS_ORIGINS "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "http://localhost")
SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ Aegis is ready! ║"
echo "╠═══════════════════════════════════════════════════════════════╣"
echo "║ ║"
echo "║ Application: http://${SERVER_IP}:${FRONTEND_PORT} "
echo "║ API Docs: http://${SERVER_IP}:${FRONTEND_PORT}/api/v1/docs "
echo "║ ║"
echo "║ Default login: admin / admin123 ║"
echo "║ ║"
echo "║ ⚠ IMPORTANT: ║"
echo "║ • Change the default password immediately ║"
echo "║ • Set up HTTPS/TLS for internet-facing deployments ║"
echo "║ • Configure firewall rules as needed ║"
echo "║ • Set up regular database backups ║"
echo "║ ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
echo "Useful commands:"
echo " View logs: docker logs -f aegis-backend"
echo " Stop: $COMPOSE_CMD -f docker-compose.prod.yml down"
echo " Restart: $COMPOSE_CMD -f docker-compose.prod.yml restart"
echo " Update: $COMPOSE_CMD -f docker-compose.prod.yml up -d --build"
echo " DB backup: docker exec aegis-postgres pg_dump -U postgres attackdb > backup.sql"
echo -e "${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ ║${NC}"
echo -e "${BOLD}${GREEN}Aegis is ready!${NC}${BOLD}${NC}"
echo -e "${BOLD}║ ║${NC}"
echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}"
echo -e "${BOLD}${NC} ${BOLD}${NC}"
echo -e "${BOLD}${NC} ${CYAN}Application${NC} ${ORIGIN_URL}"
echo -e "${BOLD}${NC} ${CYAN}Local IP${NC} http://${SERVER_IP}:${FRONTEND_PORT}"
echo -e "${BOLD}${NC} ${BOLD}${NC}"
echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}"
echo -e "${BOLD}${NC} ${BOLD}${NC}"
echo -e "${BOLD}${NC} ${CYAN}Admin Login${NC} ${BOLD}${NC}"
echo -e "${BOLD}${NC} Username: ${GREEN}${ADMIN_CREDS_USER}${NC}"
if [ -n "$ADMIN_CREDS_PASS" ]; then
echo -e "${BOLD}${NC} Password: ${GREEN}${ADMIN_CREDS_PASS}${NC}"
else
echo -e "${BOLD}${NC} Password: ${YELLOW}(check: docker logs aegis-backend | grep Password)${NC}"
fi
echo -e "${BOLD}${NC} ${BOLD}${NC}"
echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}"
echo -e "${BOLD}${NC} ${BOLD}${NC}"
echo -e "${BOLD}${NC} ${YELLOW}Important:${NC} ${BOLD}${NC}"
echo -e "${BOLD}${NC} - Save the admin password now if auto-generated ${BOLD}${NC}"
echo -e "${BOLD}${NC} - Set up HTTPS/TLS for internet-facing deployments ${BOLD}${NC}"
echo -e "${BOLD}${NC} - Configure firewall rules as needed ${BOLD}${NC}"
echo -e "${BOLD}${NC} - Set up regular database backups ${BOLD}${NC}"
echo -e "${BOLD}${NC} ${BOLD}${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BOLD}Useful commands:${NC}"
echo -e " ${DIM}View logs${NC} docker logs -f aegis-backend"
echo -e " ${DIM}Stop${NC} $COMPOSE_CMD -f docker-compose.prod.yml down"
echo -e " ${DIM}Restart${NC} $COMPOSE_CMD -f docker-compose.prod.yml restart"
echo -e " ${DIM}Update${NC} $COMPOSE_CMD -f docker-compose.prod.yml up -d --build"
echo -e " ${DIM}DB backup${NC} docker exec aegis-postgres pg_dump -U postgres ${DB_NAME:-attackdb} > backup.sql"
echo -e " ${DIM}Reconfigure${NC} ./scripts/install.sh"
echo ""