From 64d64080e08f82c3e4fb13dc47dd578e5a7bd4a4 Mon Sep 17 00:00:00 2001
From: Kitos
Date: Wed, 11 Feb 2026 08:56:26 +0100
Subject: [PATCH] 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)
---
.env.example | 32 +-
backend/Dockerfile | 6 +
backend/app/auth.py | 49 +-
backend/app/config.py | 60 +-
backend/app/dependencies/auth.py | 37 +-
backend/app/main.py | 40 +-
backend/app/routers/auth.py | 99 +++-
backend/app/routers/campaigns.py | 3 +-
backend/app/routers/d3fend.py | 3 +-
backend/app/routers/data_sources.py | 44 +-
backend/app/routers/detection_rules.py | 41 +-
backend/app/routers/evidence.py | 62 +-
backend/app/routers/heatmap.py | 3 +-
backend/app/routers/reports.py | 6 +-
backend/app/routers/system.py | 5 +-
backend/app/routers/test_templates.py | 6 +-
backend/app/routers/tests.py | 19 +-
backend/app/routers/threat_actors.py | 6 +-
backend/app/schemas/test_template.py | 2 +-
backend/app/schemas/user.py | 54 +-
backend/app/seed.py | 60 +-
backend/app/services/atomic_import_service.py | 44 +-
.../app/services/elastic_import_service.py | 44 +-
backend/app/services/intel_service.py | 2 +-
backend/app/services/sigma_import_service.py | 44 +-
backend/app/storage.py | 4 +-
backend/app/utils.py | 21 +
backend/requirements.txt | 2 +
docker-compose.prod.yml | 6 +-
docker-compose.yml | 12 +-
frontend/nginx.conf | 32 +-
frontend/src/api/auth.ts | 25 +-
frontend/src/api/client.ts | 14 +-
frontend/src/components/EvidenceUpload.tsx | 2 +-
frontend/src/context/AuthContext.tsx | 26 +-
scripts/install.sh | 550 +++++++++++++-----
36 files changed, 1154 insertions(+), 311 deletions(-)
create mode 100644 backend/app/utils.py
diff --git a/.env.example b/.env.example
index 66d5373..a1d51b7 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 0303694..0daba8c 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -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
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 5deaa33..97a5a8d 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -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]
diff --git a/backend/app/config.py b/backend/app/config.py
index 82920f1..8883fee 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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,
+ )
diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py
index 7775d33..ed3ffd8 100644
--- a/backend/app/dependencies/auth.py
+++ b/backend/app/dependencies/auth.py
@@ -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 `` 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
diff --git a/backend/app/main.py b/backend/app/main.py
index 68dbf81..fc346db 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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"}
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 3a10a96..1306030 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py
index 68bd386..d1b3da4 100644
--- a/backend/app/routers/campaigns.py
+++ b/backend/app/routers/campaigns.py
@@ -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()
diff --git a/backend/app/routers/d3fend.py b/backend/app/routers/d3fend.py
index f8b10a8..307936e 100644
--- a/backend/app/routers/d3fend.py
+++ b/backend/app/routers/d3fend.py
@@ -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)
diff --git a/backend/app/routers/data_sources.py b/backend/app/routers/data_sources.py
index 12425bb..8809237 100644
--- a/backend/app/routers/data_sources.py
+++ b/backend/app/routers/data_sources.py
@@ -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(
diff --git a/backend/app/routers/detection_rules.py b/backend/app/routers/detection_rules.py
index 8222268..d84c41d 100644
--- a/backend/app/routers/detection_rules.py
+++ b/backend/app/routers/detection_rules.py
@@ -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
diff --git a/backend/app/routers/evidence.py b/backend/app/routers/evidence.py
index c05a48f..29d68a4 100644
--- a/backend/app/routers/evidence.py
+++ b/backend/app/routers/evidence.py
@@ -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,
diff --git a/backend/app/routers/heatmap.py b/backend/app/routers/heatmap.py
index cb57344..1cacbda 100644
--- a/backend/app/routers/heatmap.py
+++ b/backend/app/routers/heatmap.py
@@ -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
diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py
index 9ad88ad..9a552e5 100644
--- a/backend/app/routers/reports.py
+++ b/backend/app/routers/reports.py
@@ -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()
diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py
index d47161c..03c3820 100644
--- a/backend/app/routers/system.py
+++ b/backend/app/routers/system.py
@@ -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 {
diff --git a/backend/app/routers/test_templates.py b/backend/app/routers/test_templates.py
index 45f0e69..73591ac 100644
--- a/backend/app/routers/test_templates.py
+++ b/backend/app/routers/test_templates.py
@@ -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),
diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py
index dbb72fb..79e8574 100644
--- a/backend/app/routers/tests.py
+++ b/backend/app/routers/tests.py
@@ -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,
diff --git a/backend/app/routers/threat_actors.py b/backend/app/routers/threat_actors.py
index cdaa743..b780ef1 100644
--- a/backend/app/routers/threat_actors.py
+++ b/backend/app/routers/threat_actors.py
@@ -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
diff --git a/backend/app/schemas/test_template.py b/backend/app/schemas/test_template.py
index fd10331..54a45e5 100644
--- a/backend/app/schemas/test_template.py
+++ b/backend/app/schemas/test_template.py
@@ -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")
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 5b0b4cb..bb9fc9c 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -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) ─────────────────────────────────────────────────────
diff --git a/backend/app/seed.py b/backend/app/seed.py
index ef695de..7ca16e9 100644
--- a/backend/app/seed.py
+++ b/backend/app/seed.py
@@ -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()
diff --git a/backend/app/services/atomic_import_service.py b/backend/app/services/atomic_import_service.py
index d1e7045..0ffcd02 100644
--- a/backend/app/services/atomic_import_service.py
+++ b/backend/app/services/atomic_import_service.py
@@ -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(
diff --git a/backend/app/services/elastic_import_service.py b/backend/app/services/elastic_import_service.py
index 98084c3..3a7d7c8 100644
--- a/backend/app/services/elastic_import_service.py
+++ b/backend/app/services/elastic_import_service.py
@@ -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(
diff --git a/backend/app/services/intel_service.py b/backend/app/services/intel_service.py
index 2962100..d18ff92 100644
--- a/backend/app/services/intel_service.py
+++ b/backend/app/services/intel_service.py
@@ -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
diff --git a/backend/app/services/sigma_import_service.py b/backend/app/services/sigma_import_service.py
index ca09912..aac9178 100644
--- a/backend/app/services/sigma_import_service.py
+++ b/backend/app/services/sigma_import_service.py
@@ -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(
diff --git a/backend/app/storage.py b/backend/app/storage.py
index 03603b8..694c63b 100644
--- a/backend/app/storage.py
+++ b/backend/app/storage.py
@@ -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
diff --git a/backend/app/utils.py b/backend/app/utils.py
new file mode 100644
index 0000000..8adaefb
--- /dev/null
+++ b/backend/app/utils.py
@@ -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("_", "\\_")
+ )
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 0a5552e..23ccb02 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -15,6 +15,8 @@ toml
taxii2-client
python-multipart
pydantic-settings
+slowapi
+defusedxml
# Testing
pytest
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index b1a2be7..3bde8b1 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index d420b6f..a7f7fb3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 323ffcf..09124d9 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -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;
}
}
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
index 5bf29bc..10ea33a 100644
--- a/frontend/src/api/auth.ts
+++ b/frontend/src/api/auth.ts
@@ -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 {
+): Promise {
const params = new URLSearchParams();
params.append("username", username);
params.append("password", password);
- const { data } = await client.post("/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 {
+ try {
+ await client.post("/auth/logout");
+ } catch {
+ // Best-effort — the cookie will expire anyway
+ }
}
/** Fetch the currently authenticated user profile. */
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 94a60e6..14816aa 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -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";
diff --git a/frontend/src/components/EvidenceUpload.tsx b/frontend/src/components/EvidenceUpload.tsx
index 8c77fc5..2cda3c0 100644
--- a/frontend/src/components/EvidenceUpload.tsx
+++ b/frontend/src/components/EvidenceUpload.tsx
@@ -93,7 +93,7 @@ export default function EvidenceUpload({ onUpload, isUploading }: EvidenceUpload
)}
- Screenshots, logs, pcap files, etc.
+ Screenshots, logs, pcap files, etc. (max 50 MB)
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
index eb3c4df..a35802f 100644
--- a/frontend/src/context/AuthContext.tsx
+++ b/frontend/src/context/AuthContext.tsx
@@ -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(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);
}, []);
diff --git a/scripts/install.sh b/scripts/install.sh
index 24c1648..40f4e28 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -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" < "$ENV_FILE" <&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 ""