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