fix: resolve 20 security vulnerabilities from comprehensive audit
Critical (1-3): - Replace hardcoded admin credentials with secure auto-generation (seed.py) - Enforce SECRET_KEY configuration, fail in production if missing (config.py) - Add Zip Slip and Zip Bomb protection to all ZIP import services High/Medium (4-9): - Add 50MB file size limit and extension whitelist to evidence uploads - Configure CORS origins via environment variable instead of hardcoded - Migrate JWT storage from localStorage to HttpOnly cookies (frontend+backend) - Add rate limiting (5/min) on login endpoint via slowapi - Replace generic dict payloads with Pydantic schemas (mass assignment) Medium (10-17): - Check is_active on login to prevent disabled users from authenticating - Sanitize exception messages in API responses (system, data_sources) - Escape LIKE wildcards in all ilike search filters across 8 routers - Run Docker container as non-root user (appuser) - Make MINIO_SECURE configurable via environment variable - Add password complexity policy (12+ chars, upper/lower/digit/special) - Implement JWT token revocation via in-memory blacklist + reduce TTL to 15min - Replace xml.etree with defusedxml to prevent Billion Laughs attacks Low (18-20): - Add security headers to Nginx (CSP, X-Frame-Options, HSTS-ready, etc.) - Disable Swagger UI/ReDoc/OpenAPI in production - Restrict /health endpoint to internal networks via Nginx ACL Also: rewrite install.sh as interactive wizard for guided deployment, fix test-from-template validation error (technique_id UUID vs MITRE ID)
This commit is contained in:
32
.env.example
32
.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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -2,25 +2,32 @@
|
||||
Authentication and RBAC dependencies for FastAPI.
|
||||
|
||||
Provides:
|
||||
- ``get_current_user``: decodes JWT, fetches user from DB, raises 401 on failure.
|
||||
- ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or
|
||||
Authorization header (fallback), fetches user from DB, raises 401 on failure.
|
||||
- ``require_role``: factory that returns a dependency enforcing a specific role
|
||||
(admins always pass).
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import is_token_blacklisted
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth2 scheme
|
||||
# OAuth2 scheme (reads Authorization header — used as fallback / Swagger UI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
|
||||
|
||||
# Cookie name — must match the one set in the auth router
|
||||
_COOKIE_NAME = "aegis_token"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Current-user dependency
|
||||
@@ -28,12 +35,19 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
aegis_token: Optional[str] = Cookie(None),
|
||||
bearer_token: Optional[str] = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""Decode the JWT *token*, look up the user in *db*, and return it.
|
||||
"""Decode the JWT, look up the user in *db*, and return it.
|
||||
|
||||
Token resolution order:
|
||||
1. ``aegis_token`` **HttpOnly cookie** (preferred — immune to XSS).
|
||||
2. ``Authorization: Bearer <token>`` header (fallback for API clients
|
||||
and Swagger UI).
|
||||
|
||||
Raises :class:`~fastapi.HTTPException` **401** when:
|
||||
- no token is found in either location,
|
||||
- the token cannot be decoded,
|
||||
- the ``sub`` claim is missing, or
|
||||
- no matching active user exists in the database.
|
||||
@@ -44,6 +58,11 @@ async def get_current_user(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Prefer cookie, fall back to header
|
||||
token = aegis_token or bearer_token
|
||||
if token is None:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
@@ -53,11 +72,15 @@ async def get_current_user(
|
||||
username: str | None = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
# Check token blacklist (revoked tokens)
|
||||
jti: str | None = payload.get("jti")
|
||||
if jti and is_token_blacklisted(jti):
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,9 +13,11 @@ from app.config import settings
|
||||
# Shared client (module-level singleton)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_scheme = "https" if settings.MINIO_SECURE else "http"
|
||||
|
||||
_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"http://{settings.MINIO_ENDPOINT}",
|
||||
endpoint_url=f"{_scheme}://{settings.MINIO_ENDPOINT}",
|
||||
aws_access_key_id=settings.MINIO_ACCESS_KEY,
|
||||
aws_secret_access_key=settings.MINIO_SECRET_KEY,
|
||||
region_name="us-east-1", # MinIO ignores this but boto3 requires it
|
||||
|
||||
21
backend/app/utils.py
Normal file
21
backend/app/utils.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Shared utility helpers."""
|
||||
|
||||
|
||||
def escape_like(value: str) -> str:
|
||||
"""Escape SQL LIKE wildcard characters (``%`` and ``_``).
|
||||
|
||||
Prevents user-supplied search terms from being interpreted as LIKE
|
||||
pattern metacharacters when used with SQLAlchemy's ``ilike``/``like``
|
||||
methods.
|
||||
|
||||
Usage::
|
||||
|
||||
from app.utils import escape_like
|
||||
query.filter(Model.name.ilike(f"%{escape_like(term)}%"))
|
||||
"""
|
||||
return (
|
||||
value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
@@ -15,6 +15,8 @@ toml
|
||||
taxii2-client
|
||||
python-multipart
|
||||
pydantic-settings
|
||||
slowapi
|
||||
defusedxml
|
||||
|
||||
# Testing
|
||||
pytest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import client from "./client";
|
||||
import type { User } from "../types/models";
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** Authenticate and return the access token. */
|
||||
/**
|
||||
* Authenticate the user.
|
||||
*
|
||||
* The backend sets an HttpOnly cookie with the JWT — no token is stored
|
||||
* in JavaScript memory or localStorage.
|
||||
*/
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("username", username);
|
||||
params.append("password", password);
|
||||
|
||||
const { data } = await client.post<TokenResponse>("/auth/login", params, {
|
||||
await client.post("/auth/login", params, {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
}
|
||||
|
||||
return data.access_token;
|
||||
/** Clear the authentication cookie on the server. */
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await client.post("/auth/logout");
|
||||
} catch {
|
||||
// Best-effort — the cookie will expire anyway
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the currently authenticated user profile. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function EvidenceUpload({ onUpload, isUploading }: EvidenceUpload
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Screenshots, logs, pcap files, etc.
|
||||
Screenshots, logs, pcap files, etc. (max 50 MB)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { login as apiLogin, getMe } from "../api/auth";
|
||||
import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
getMe,
|
||||
} from "../api/auth";
|
||||
import type { User } from "../types/models";
|
||||
|
||||
/* ── Context shape ────────────────────────────────────────────────── */
|
||||
@@ -27,29 +31,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// On mount — check for a persisted token and hydrate the user
|
||||
// On mount — try to hydrate the user from the existing HttpOnly cookie.
|
||||
// If no valid cookie exists the /auth/me call will 401 and we stay
|
||||
// unauthenticated — no localStorage involved.
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
getMe()
|
||||
.then(setUser)
|
||||
.catch(() => localStorage.removeItem("token"))
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const token = await apiLogin(username, password);
|
||||
localStorage.setItem("token", token);
|
||||
// The backend sets the HttpOnly cookie automatically
|
||||
await apiLogin(username, password);
|
||||
const me = await getMe();
|
||||
setUser(me);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("token");
|
||||
const logout = useCallback(async () => {
|
||||
await apiLogout();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Aegis - Production Installation Script
|
||||
# Aegis - Interactive Production Installer
|
||||
# =============================================================================
|
||||
# This script sets up the Aegis platform for production.
|
||||
# Sets up the Aegis platform for production with an interactive wizard
|
||||
# that configures all environment variables.
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x scripts/install.sh
|
||||
@@ -10,7 +11,7 @@
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker and Docker Compose installed
|
||||
# - Port 80 (or FRONTEND_PORT) available
|
||||
# - Port 80 (or chosen port) available
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
@@ -20,33 +21,58 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Colors
|
||||
# ── Colors & helpers ──────────────────────────────────────────────────────
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_ok() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
print_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
print_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
print_info() { echo -e "${CYAN}[i]${NC} $1"; }
|
||||
print_header() { echo -e "\n${BOLD}── $1 ──${NC}"; }
|
||||
print_ok() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
print_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
print_error() { echo -e "${RED}[X]${NC} $1"; }
|
||||
print_info() { echo -e "${CYAN}[i]${NC} $1"; }
|
||||
print_header() { echo -e "\n${BOLD}── $1 ──${NC}\n"; }
|
||||
print_prompt() { echo -en "${CYAN}>>>${NC} $1"; }
|
||||
|
||||
# Generate a cryptographically secure random string
|
||||
gen_secret() {
|
||||
python3 -c "import secrets; print(secrets.token_hex($1))" 2>/dev/null \
|
||||
|| openssl rand -hex "$1" 2>/dev/null \
|
||||
|| head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||
}
|
||||
|
||||
gen_password() {
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe($1))" 2>/dev/null \
|
||||
|| openssl rand -base64 "$1" 2>/dev/null \
|
||||
|| head -c "$1" /dev/urandom | base64 | tr -d '=/+' | head -c "$1"
|
||||
}
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────────────
|
||||
|
||||
clear 2>/dev/null || true
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Aegis - Production Installation ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}"
|
||||
echo " ╔══════════════════════════════════════════════════════════╗"
|
||||
echo " ║ ║"
|
||||
echo " ║ Aegis - Installation Wizard ║"
|
||||
echo " ║ MITRE ATT&CK Coverage Platform ║"
|
||||
echo " ║ ║"
|
||||
echo " ╚══════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# ── 1. Check prerequisites ──────────────────────────────────────────
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# STEP 1: Check prerequisites
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
print_header "Checking prerequisites"
|
||||
print_header "Step 1/5 - Checking prerequisites"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
print_error "Docker is not installed. Please install Docker first."
|
||||
echo " -> https://docs.docker.com/engine/install/"
|
||||
echo " https://docs.docker.com/engine/install/"
|
||||
exit 1
|
||||
fi
|
||||
print_ok "Docker found: $(docker --version | head -1)"
|
||||
@@ -57,236 +83,444 @@ if ! docker info > /dev/null 2>&1; then
|
||||
fi
|
||||
print_ok "Docker daemon is running"
|
||||
|
||||
# Check for docker compose (v2 plugin or standalone)
|
||||
if docker compose version > /dev/null 2>&1; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
print_error "Docker Compose is not installed."
|
||||
echo " -> https://docs.docker.com/compose/install/"
|
||||
echo " https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
print_ok "Docker Compose found ($COMPOSE_CMD)"
|
||||
|
||||
# Auto-detect Docker API version to avoid client/server mismatch
|
||||
# Auto-detect Docker API version
|
||||
DOCKER_SERVER_API=$(docker version --format '{{.Server.APIVersion}}' 2>/dev/null || echo "")
|
||||
if [ -n "$DOCKER_SERVER_API" ]; then
|
||||
export DOCKER_API_VERSION="$DOCKER_SERVER_API"
|
||||
print_info "Docker API version: $DOCKER_SERVER_API"
|
||||
fi
|
||||
|
||||
# ── 2. Setup .env file ──────────────────────────────────────────────
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# STEP 2: Interactive configuration
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
print_header "Environment configuration"
|
||||
print_header "Step 2/5 - Configuration"
|
||||
|
||||
ENV_FILE=".env"
|
||||
SKIP_CONFIG=false
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
print_warn ".env file already exists"
|
||||
read -p " Overwrite with new values? (y/N) " -n 1 -r
|
||||
print_warn "An existing .env file was found."
|
||||
echo ""
|
||||
print_prompt "Do you want to reconfigure? (y/N): "
|
||||
read -r REPLY
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_info "Keeping existing .env file"
|
||||
SKIP_ENV=true
|
||||
print_info "Keeping existing configuration."
|
||||
SKIP_CONFIG=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${SKIP_ENV}" != "true" ]; then
|
||||
# Generate secure secrets
|
||||
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
DB_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))" 2>/dev/null || openssl rand -base64 24 2>/dev/null || head -c 24 /dev/urandom | base64)
|
||||
MINIO_SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(24))" 2>/dev/null || openssl rand -base64 24 2>/dev/null || head -c 24 /dev/urandom | base64)
|
||||
if [ "$SKIP_CONFIG" = false ]; then
|
||||
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# ── Aegis Production Environment ─────────────────────────────────
|
||||
echo -e " ${DIM}Answer the following questions to configure Aegis."
|
||||
echo -e " Press Enter to accept the default value shown in [brackets].${NC}"
|
||||
echo ""
|
||||
|
||||
# ── Domain / URL ──────────────────────────────────────────────────
|
||||
|
||||
echo -e " ${BOLD}1. Domain Configuration${NC}"
|
||||
echo -e " ${DIM}The domain where Aegis will be accessible."
|
||||
echo -e " Examples: aegis.example.com, 192.168.1.100, localhost${NC}"
|
||||
echo ""
|
||||
print_prompt "Domain or IP [localhost]: "
|
||||
read -r INPUT_DOMAIN
|
||||
DOMAIN="${INPUT_DOMAIN:-localhost}"
|
||||
|
||||
# ── Protocol ──────────────────────────────────────────────────────
|
||||
|
||||
if [ "$DOMAIN" = "localhost" ] || [ "$DOMAIN" = "127.0.0.1" ]; then
|
||||
PROTOCOL="http"
|
||||
print_info "Using HTTP for local deployment"
|
||||
else
|
||||
echo ""
|
||||
print_prompt "Are you using HTTPS/SSL? (Y/n): "
|
||||
read -r REPLY
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
PROTOCOL="http"
|
||||
else
|
||||
PROTOCOL="https"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Port ──────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$PROTOCOL" = "https" ]; then
|
||||
DEFAULT_PORT=443
|
||||
else
|
||||
DEFAULT_PORT=80
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}2. Port${NC}"
|
||||
print_prompt "Frontend port [$DEFAULT_PORT]: "
|
||||
read -r INPUT_PORT
|
||||
FRONTEND_PORT="${INPUT_PORT:-$DEFAULT_PORT}"
|
||||
|
||||
# Build the full origin URL for CORS
|
||||
if { [ "$PROTOCOL" = "https" ] && [ "$FRONTEND_PORT" = "443" ]; } || \
|
||||
{ [ "$PROTOCOL" = "http" ] && [ "$FRONTEND_PORT" = "80" ]; }; then
|
||||
ORIGIN_URL="${PROTOCOL}://${DOMAIN}"
|
||||
else
|
||||
ORIGIN_URL="${PROTOCOL}://${DOMAIN}:${FRONTEND_PORT}"
|
||||
fi
|
||||
|
||||
# ── Admin account ─────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}3. Admin Account${NC}"
|
||||
echo -e " ${DIM}The initial administrator account for Aegis.${NC}"
|
||||
echo ""
|
||||
print_prompt "Admin username [admin]: "
|
||||
read -r INPUT_ADMIN_USER
|
||||
ADMIN_USERNAME="${INPUT_ADMIN_USER:-admin}"
|
||||
|
||||
echo ""
|
||||
echo -e " ${DIM}Leave empty to auto-generate a secure password."
|
||||
echo -e " The password will be shown in the installation summary.${NC}"
|
||||
print_prompt "Admin password [auto-generate]: "
|
||||
read -rs INPUT_ADMIN_PASS
|
||||
echo ""
|
||||
ADMIN_PASSWORD="${INPUT_ADMIN_PASS}"
|
||||
|
||||
if [ -z "$ADMIN_PASSWORD" ]; then
|
||||
ADMIN_PASSWORD=$(gen_password 18)
|
||||
ADMIN_PW_GENERATED=true
|
||||
print_info "Password will be auto-generated"
|
||||
else
|
||||
ADMIN_PW_GENERATED=false
|
||||
print_ok "Password set"
|
||||
fi
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}4. Database${NC}"
|
||||
print_prompt "Database name [attackdb]: "
|
||||
read -r INPUT_DB_NAME
|
||||
DB_NAME="${INPUT_DB_NAME:-attackdb}"
|
||||
|
||||
print_prompt "Database user [postgres]: "
|
||||
read -r INPUT_DB_USER
|
||||
DB_USER="${INPUT_DB_USER:-postgres}"
|
||||
|
||||
echo -e " ${DIM}Leave empty to auto-generate a secure password.${NC}"
|
||||
print_prompt "Database password [auto-generate]: "
|
||||
read -rs INPUT_DB_PASS
|
||||
echo ""
|
||||
if [ -z "$INPUT_DB_PASS" ]; then
|
||||
DB_PASSWORD=$(gen_password 24)
|
||||
print_info "Database password auto-generated"
|
||||
else
|
||||
DB_PASSWORD="$INPUT_DB_PASS"
|
||||
print_ok "Database password set"
|
||||
fi
|
||||
|
||||
# ── Token expiry ──────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}5. Session Duration${NC}"
|
||||
print_prompt "Token expiry in minutes [60]: "
|
||||
read -r INPUT_TOKEN_EXP
|
||||
TOKEN_EXPIRE_MINUTES="${INPUT_TOKEN_EXP:-60}"
|
||||
|
||||
# ── MITRE sync ────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}6. Initial Data${NC}"
|
||||
print_prompt "Run MITRE ATT&CK sync after install? (Y/n): "
|
||||
read -r INPUT_SYNC
|
||||
if [[ $INPUT_SYNC =~ ^[Nn]$ ]]; then
|
||||
RUN_MITRE_SYNC=false
|
||||
else
|
||||
RUN_MITRE_SYNC=true
|
||||
fi
|
||||
|
||||
# ── Generate secrets ──────────────────────────────────────────────
|
||||
|
||||
SECRET_KEY=$(gen_secret 32)
|
||||
MINIO_SECRET=$(gen_password 24)
|
||||
|
||||
# ── Show summary before writing ──────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD} ┌──────────────────────────────────────────────────────┐${NC}"
|
||||
echo -e "${BOLD} │ Configuration Summary │${NC}"
|
||||
echo -e "${BOLD} ├──────────────────────────────────────────────────────┤${NC}"
|
||||
echo -e " │ URL: ${CYAN}${ORIGIN_URL}${NC}"
|
||||
echo -e " │ Admin user: ${CYAN}${ADMIN_USERNAME}${NC}"
|
||||
if [ "$ADMIN_PW_GENERATED" = true ]; then
|
||||
echo -e " │ Admin pass: ${CYAN}(auto-generated)${NC}"
|
||||
else
|
||||
echo -e " │ Admin pass: ${CYAN}(custom)${NC}"
|
||||
fi
|
||||
echo -e " │ Database: ${CYAN}${DB_USER}@${DB_NAME}${NC}"
|
||||
echo -e " │ Port: ${CYAN}${FRONTEND_PORT}${NC}"
|
||||
echo -e " │ Session TTL: ${CYAN}${TOKEN_EXPIRE_MINUTES} min${NC}"
|
||||
echo -e " │ MITRE sync: ${CYAN}$([ "$RUN_MITRE_SYNC" = true ] && echo "yes" || echo "no")${NC}"
|
||||
echo -e "${BOLD} └──────────────────────────────────────────────────────┘${NC}"
|
||||
echo ""
|
||||
print_prompt "Proceed with these settings? (Y/n): "
|
||||
read -r CONFIRM
|
||||
if [[ $CONFIRM =~ ^[Nn]$ ]]; then
|
||||
print_warn "Installation cancelled. Run the script again to reconfigure."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Write .env ────────────────────────────────────────────────────
|
||||
|
||||
cat > "$ENV_FILE" <<ENVEOF
|
||||
# =============================================================================
|
||||
# Aegis Production Environment
|
||||
# Generated by install.sh on $(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
# =============================================================================
|
||||
|
||||
# Database
|
||||
DB_USER=postgres
|
||||
# ── Database ─────────────────────────────────────────────────────────────────
|
||||
DB_USER=${DB_USER}
|
||||
DB_PASSWORD=${DB_PASSWORD}
|
||||
DB_NAME=attackdb
|
||||
DB_NAME=${DB_NAME}
|
||||
|
||||
# Security
|
||||
# ── Security ─────────────────────────────────────────────────────────────────
|
||||
SECRET_KEY=${SECRET_KEY}
|
||||
TOKEN_EXPIRE_MINUTES=60
|
||||
TOKEN_EXPIRE_MINUTES=${TOKEN_EXPIRE_MINUTES}
|
||||
|
||||
# MinIO Object Storage
|
||||
# ── Initial Admin Account ────────────────────────────────────────────────────
|
||||
ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
|
||||
# ── MinIO Object Storage ─────────────────────────────────────────────────────
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=${MINIO_SECRET}
|
||||
MINIO_BUCKET=evidence
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=80
|
||||
EOF
|
||||
# ── CORS (allowed frontend origins) ─────────────────────────────────────────
|
||||
CORS_ORIGINS=${ORIGIN_URL}
|
||||
|
||||
print_ok ".env file created with secure random secrets"
|
||||
print_info "Review and edit .env if needed before proceeding"
|
||||
fi
|
||||
# ── Frontend ─────────────────────────────────────────────────────────────────
|
||||
FRONTEND_PORT=${FRONTEND_PORT}
|
||||
|
||||
# ── 3. Build and start containers ────────────────────────────────────
|
||||
# ── Environment ──────────────────────────────────────────────────────────────
|
||||
AEGIS_ENV=production
|
||||
ENVEOF
|
||||
|
||||
print_header "Building and starting containers"
|
||||
print_ok ".env file created with secure configuration"
|
||||
|
||||
print_info "This may take a few minutes on first run..."
|
||||
print_info "Project root: $PROJECT_ROOT"
|
||||
fi # end SKIP_CONFIG
|
||||
|
||||
if ! $COMPOSE_CMD -f docker-compose.prod.yml up -d --build; then
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# STEP 3: Build and start
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
print_header "Step 3/5 - Building and starting containers"
|
||||
|
||||
print_info "This may take several minutes on first run..."
|
||||
|
||||
if ! $COMPOSE_CMD -f docker-compose.prod.yml up -d --build 2>&1; then
|
||||
print_error "Failed to build/start containers. Check the output above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_ok "Containers started"
|
||||
|
||||
# ── 4. Wait for services to be healthy ───────────────────────────────
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# STEP 4: Wait for services
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
print_header "Waiting for services"
|
||||
print_header "Step 4/5 - Waiting for services to be ready"
|
||||
|
||||
# Wait for postgres
|
||||
print_info "Waiting for PostgreSQL..."
|
||||
# Wait for PostgreSQL
|
||||
echo -en " PostgreSQL ..."
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
until docker exec aegis-postgres pg_isready -U postgres > /dev/null 2>&1; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ $RETRY -ge $MAX_RETRIES ]; then
|
||||
print_error "PostgreSQL failed to start after $MAX_RETRIES attempts"
|
||||
echo " Check logs: docker logs aegis-postgres"
|
||||
echo ""
|
||||
print_error "PostgreSQL failed to start. Check: docker logs aegis-postgres"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
print_ok "PostgreSQL is ready"
|
||||
echo -e " ${GREEN}ready${NC}"
|
||||
|
||||
# Wait for backend (which runs migrations + seed on startup)
|
||||
print_info "Waiting for backend (running migrations and seeds)..."
|
||||
# Wait for backend (runs migrations + seed)
|
||||
echo -en " Backend (migrations + seed) ..."
|
||||
RETRY=0
|
||||
until docker exec aegis-backend curl -sf http://localhost:8000/health > /dev/null 2>&1; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ $RETRY -ge 90 ]; then
|
||||
print_error "Backend failed to start after 180 seconds"
|
||||
echo " Check logs: docker logs aegis-backend"
|
||||
echo ""
|
||||
print_error "Backend failed to start after 3 minutes."
|
||||
echo " Check: docker logs aegis-backend"
|
||||
exit 1
|
||||
fi
|
||||
# Show progress every 10 attempts
|
||||
if [ $((RETRY % 5)) -eq 0 ]; then
|
||||
print_info " Still waiting... ($RETRY attempts, checking logs)"
|
||||
docker logs aegis-backend --tail 3 2>/dev/null | while IFS= read -r line; do echo " $line"; done
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
print_ok "Backend is ready (migrations and seeds completed)"
|
||||
echo -e " ${GREEN}ready${NC}"
|
||||
|
||||
# Wait for frontend
|
||||
print_info "Waiting for frontend..."
|
||||
RETRY=0
|
||||
FRONTEND_PORT=$(grep FRONTEND_PORT "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "80")
|
||||
FRONTEND_PORT=${FRONTEND_PORT:-80}
|
||||
|
||||
echo -en " Frontend ..."
|
||||
RETRY=0
|
||||
until curl -sf "http://localhost:${FRONTEND_PORT}" > /dev/null 2>&1; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ $RETRY -ge 30 ]; then
|
||||
print_error "Frontend failed to start after 60 seconds"
|
||||
echo " Check logs: docker logs aegis-frontend"
|
||||
echo ""
|
||||
print_error "Frontend failed to start. Check: docker logs aegis-frontend"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
print_ok "Frontend is ready"
|
||||
echo -e " ${GREEN}ready${NC}"
|
||||
|
||||
# ── 5. Trigger MITRE ATT&CK sync ────────────────────────────────────
|
||||
print_ok "All services are running"
|
||||
|
||||
print_header "Initial data sync"
|
||||
# ── Extract admin credentials from backend logs ──────────────────────
|
||||
|
||||
echo ""
|
||||
read -p "Run initial MITRE ATT&CK sync? This imports ~700 techniques. (Y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
print_info "Authenticating..."
|
||||
ADMIN_CREDS_USER=""
|
||||
ADMIN_CREDS_PASS=""
|
||||
|
||||
# Get admin token (try via nginx first, then directly to backend container)
|
||||
API_URL="http://localhost:${FRONTEND_PORT}/api/v1"
|
||||
TOKEN=$(curl -sf --max-time 10 -X POST "${API_URL}/auth/login" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=admin&password=admin123" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
|
||||
# Try to extract the credentials from the backend startup logs
|
||||
LOG_OUTPUT=$(docker logs aegis-backend 2>&1 | tail -20)
|
||||
|
||||
# Fallback: try directly via backend container
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "" ]; then
|
||||
TOKEN=$(docker exec aegis-backend curl -sf -X POST "http://localhost:8000/api/v1/auth/login" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=admin&password=admin123" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
|
||||
API_URL="http://localhost:8000/api/v1"
|
||||
API_VIA_DOCKER=true
|
||||
fi
|
||||
|
||||
if [ -n "$TOKEN" ] && [ "$TOKEN" != "" ]; then
|
||||
print_info "Syncing MITRE ATT&CK data (this takes 1-2 minutes)..."
|
||||
|
||||
if [ "$API_VIA_DOCKER" = true ]; then
|
||||
SYNC_RESULT=$(docker exec aegis-backend curl -sf --max-time 300 -X POST "${API_URL}/system/sync-mitre" \
|
||||
-H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "error")
|
||||
else
|
||||
SYNC_RESULT=$(curl -sf --max-time 300 -X POST "${API_URL}/system/sync-mitre" \
|
||||
-H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "error")
|
||||
fi
|
||||
|
||||
if [ "$SYNC_RESULT" != "error" ]; then
|
||||
print_ok "MITRE ATT&CK sync completed"
|
||||
else
|
||||
print_warn "MITRE sync may have timed out. Check the System page in the UI."
|
||||
fi
|
||||
|
||||
# Sync data sources
|
||||
print_info "Syncing data sources (Atomic Red Team, SigmaHQ, etc.)..."
|
||||
if [ "$API_VIA_DOCKER" = true ]; then
|
||||
CURL_PREFIX="docker exec aegis-backend curl"
|
||||
else
|
||||
CURL_PREFIX="curl"
|
||||
fi
|
||||
|
||||
for source_id in $($CURL_PREFIX -sf "${API_URL}/data-sources" \
|
||||
-H "Authorization: Bearer $TOKEN" 2>/dev/null | \
|
||||
python3 -c "import sys,json; [print(s['id']) for s in json.load(sys.stdin)]" 2>/dev/null); do
|
||||
|
||||
$CURL_PREFIX -sf --max-time 120 -X POST "${API_URL}/data-sources/${source_id}/sync" \
|
||||
-H "Authorization: Bearer $TOKEN" > /dev/null 2>&1 || true
|
||||
done
|
||||
print_ok "Data source sync triggered"
|
||||
else
|
||||
print_warn "Could not authenticate. Run MITRE sync manually from the System page."
|
||||
print_info "Default credentials: admin / admin123"
|
||||
fi
|
||||
else
|
||||
print_info "Skipping MITRE sync. You can do this later from the System page."
|
||||
if echo "$LOG_OUTPUT" | grep -q "Initial Admin User Created"; then
|
||||
ADMIN_CREDS_USER=$(echo "$LOG_OUTPUT" | grep "Username :" | sed 's/.*Username : //')
|
||||
ADMIN_CREDS_PASS=$(echo "$LOG_OUTPUT" | grep "Password :" | sed 's/.*Password : //')
|
||||
fi
|
||||
|
||||
# ── 6. Summary ───────────────────────────────────────────────────────
|
||||
# Fallback: if we set it via env, use those values
|
||||
if [ -z "$ADMIN_CREDS_USER" ]; then
|
||||
ADMIN_CREDS_USER=$(grep ADMIN_USERNAME "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "admin")
|
||||
ADMIN_CREDS_USER="${ADMIN_CREDS_USER:-admin}"
|
||||
fi
|
||||
|
||||
# Get the server's IP
|
||||
if [ -z "$ADMIN_CREDS_PASS" ] || [ "$ADMIN_CREDS_PASS" = "(set via ADMIN_PASSWORD env var)" ]; then
|
||||
ADMIN_CREDS_PASS=$(grep ADMIN_PASSWORD "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "")
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# STEP 5: Initial data sync (optional)
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Re-read RUN_MITRE_SYNC if we skipped config
|
||||
if [ "$SKIP_CONFIG" = true ]; then
|
||||
echo ""
|
||||
print_prompt "Run initial MITRE ATT&CK sync? (~700 techniques, 1-2 min) (Y/n): "
|
||||
read -r INPUT_SYNC
|
||||
if [[ $INPUT_SYNC =~ ^[Nn]$ ]]; then
|
||||
RUN_MITRE_SYNC=false
|
||||
else
|
||||
RUN_MITRE_SYNC=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$RUN_MITRE_SYNC" = true ]; then
|
||||
print_header "Step 5/5 - Initial data sync"
|
||||
|
||||
print_info "Authenticating with backend..."
|
||||
|
||||
# Authenticate via backend container (most reliable)
|
||||
TOKEN=$(docker exec aegis-backend curl -sf -X POST "http://localhost:8000/api/v1/auth/login" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=${ADMIN_CREDS_USER}&password=${ADMIN_CREDS_PASS}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$TOKEN" ] && [ "$TOKEN" != "" ]; then
|
||||
|
||||
# MITRE ATT&CK sync
|
||||
print_info "Syncing MITRE ATT&CK techniques (1-2 minutes)..."
|
||||
SYNC_RESULT=$(docker exec aegis-backend curl -sf --max-time 300 \
|
||||
-X POST "http://localhost:8000/api/v1/system/sync-mitre" \
|
||||
-H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "error")
|
||||
|
||||
if [ "$SYNC_RESULT" != "error" ]; then
|
||||
NEW_TECHNIQUES=$(echo "$SYNC_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('new',0))" 2>/dev/null || echo "?")
|
||||
print_ok "MITRE sync completed ($NEW_TECHNIQUES techniques imported)"
|
||||
else
|
||||
print_warn "MITRE sync timed out. You can retry from the System page."
|
||||
fi
|
||||
|
||||
# Data sources sync
|
||||
print_info "Syncing data sources (Atomic Red Team, SigmaHQ, etc.)..."
|
||||
SOURCES=$(docker exec aegis-backend curl -sf "http://localhost:8000/api/v1/data-sources" \
|
||||
-H "Authorization: Bearer $TOKEN" 2>/dev/null | \
|
||||
python3 -c "import sys,json; [print(s['id']) for s in json.load(sys.stdin)]" 2>/dev/null || echo "")
|
||||
|
||||
SYNC_COUNT=0
|
||||
for source_id in $SOURCES; do
|
||||
docker exec aegis-backend curl -sf --max-time 120 \
|
||||
-X POST "http://localhost:8000/api/v1/data-sources/${source_id}/sync" \
|
||||
-H "Authorization: Bearer $TOKEN" > /dev/null 2>&1 && SYNC_COUNT=$((SYNC_COUNT + 1)) || true
|
||||
done
|
||||
if [ "$SYNC_COUNT" -gt 0 ]; then
|
||||
print_ok "Data sources synced ($SYNC_COUNT sources)"
|
||||
fi
|
||||
else
|
||||
print_warn "Could not authenticate. Run MITRE sync from the System page."
|
||||
fi
|
||||
else
|
||||
print_header "Step 5/5 - Skipping data sync"
|
||||
print_info "You can import data later from the System page."
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# FINAL SUMMARY
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Build the access URL
|
||||
ORIGIN_URL=$(grep CORS_ORIGINS "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "http://localhost")
|
||||
SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
|
||||
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Aegis is ready! ║"
|
||||
echo "╠═══════════════════════════════════════════════════════════════╣"
|
||||
echo "║ ║"
|
||||
echo "║ Application: http://${SERVER_IP}:${FRONTEND_PORT} "
|
||||
echo "║ API Docs: http://${SERVER_IP}:${FRONTEND_PORT}/api/v1/docs "
|
||||
echo "║ ║"
|
||||
echo "║ Default login: admin / admin123 ║"
|
||||
echo "║ ║"
|
||||
echo "║ ⚠ IMPORTANT: ║"
|
||||
echo "║ • Change the default password immediately ║"
|
||||
echo "║ • Set up HTTPS/TLS for internet-facing deployments ║"
|
||||
echo "║ • Configure firewall rules as needed ║"
|
||||
echo "║ • Set up regular database backups ║"
|
||||
echo "║ ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " View logs: docker logs -f aegis-backend"
|
||||
echo " Stop: $COMPOSE_CMD -f docker-compose.prod.yml down"
|
||||
echo " Restart: $COMPOSE_CMD -f docker-compose.prod.yml restart"
|
||||
echo " Update: $COMPOSE_CMD -f docker-compose.prod.yml up -d --build"
|
||||
echo " DB backup: docker exec aegis-postgres pg_dump -U postgres attackdb > backup.sql"
|
||||
echo -e "${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}║ ║${NC}"
|
||||
echo -e "${BOLD}║ ${GREEN}Aegis is ready!${NC}${BOLD} ║${NC}"
|
||||
echo -e "${BOLD}║ ║${NC}"
|
||||
echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${BOLD}║${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} ${CYAN}Application${NC} ${ORIGIN_URL}"
|
||||
echo -e "${BOLD}║${NC} ${CYAN}Local IP${NC} http://${SERVER_IP}:${FRONTEND_PORT}"
|
||||
echo -e "${BOLD}║${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${BOLD}║${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} ${CYAN}Admin Login${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} Username: ${GREEN}${ADMIN_CREDS_USER}${NC}"
|
||||
if [ -n "$ADMIN_CREDS_PASS" ]; then
|
||||
echo -e "${BOLD}║${NC} Password: ${GREEN}${ADMIN_CREDS_PASS}${NC}"
|
||||
else
|
||||
echo -e "${BOLD}║${NC} Password: ${YELLOW}(check: docker logs aegis-backend | grep Password)${NC}"
|
||||
fi
|
||||
echo -e "${BOLD}║${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${BOLD}║${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} ${YELLOW}Important:${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} - Save the admin password now if auto-generated ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} - Set up HTTPS/TLS for internet-facing deployments ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} - Configure firewall rules as needed ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} - Set up regular database backups ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}║${NC} ${BOLD}║${NC}"
|
||||
echo -e "${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${BOLD}Useful commands:${NC}"
|
||||
echo -e " ${DIM}View logs${NC} docker logs -f aegis-backend"
|
||||
echo -e " ${DIM}Stop${NC} $COMPOSE_CMD -f docker-compose.prod.yml down"
|
||||
echo -e " ${DIM}Restart${NC} $COMPOSE_CMD -f docker-compose.prod.yml restart"
|
||||
echo -e " ${DIM}Update${NC} $COMPOSE_CMD -f docker-compose.prod.yml up -d --build"
|
||||
echo -e " ${DIM}DB backup${NC} docker exec aegis-postgres pg_dump -U postgres ${DB_NAME:-attackdb} > backup.sql"
|
||||
echo -e " ${DIM}Reconfigure${NC} ./scripts/install.sh"
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user