diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c169cb2 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,23 @@ +# Mimic backend — example env. Copy to .env (gitignored) and adapt. + +MIMIC_ENV=development +MIMIC_SECRET_KEY=replace-me-with-secrets.token_urlsafe-32 +MIMIC_FERNET_KEY= + +# Database +POSTGRES_DB=mimic +POSTGRES_USER=mimic_app +POSTGRES_PASSWORD=mimic_dev_password +MIMIC_DATABASE_URL=postgresql+psycopg://mimic_app:mimic_dev_password@localhost:5432/mimic +MIMIC_DATABASE_AUDIT_URL=postgresql+psycopg://mimic_audit_writer:CHANGE_ME@localhost:5432/mimic + +# Session / cookies +MIMIC_SESSION_COOKIE_SECURE=false +MIMIC_SESSION_COOKIE_SAMESITE=Lax + +# CORS (frontend dev) +MIMIC_CORS_ORIGINS=http://localhost:5173 + +# Logging +MIMIC_LOG_LEVEL=DEBUG +MIMIC_LOG_JSON=false diff --git a/backend/.gitkeep b/backend/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6ba967e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.7 + +# --- Stage 1: build -------------------------------------------------------- +FROM python:3.12-slim-bookworm AS build + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +# WeasyPrint native deps + libpq + build tools. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libcairo2 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY pyproject.toml README.md ./ +COPY src ./src + +RUN pip install --upgrade pip wheel build \ + && pip wheel --wheel-dir /wheels --no-deps . + +RUN pip install --prefix=/install --no-warn-script-location . + +# --- Stage 2: runtime ------------------------------------------------------ +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_APP=mimic.app:create_app \ + MIMIC_ENV=production + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libcairo2 \ + libgdk-pixbuf-2.0-0 \ + shared-mime-info \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1001 mimic \ + && useradd --system --uid 1001 --gid 1001 --home-dir /app --shell /usr/sbin/nologin mimic + +WORKDIR /app +COPY --from=build /install /usr/local +COPY --chown=mimic:mimic src ./src + +USER mimic +EXPOSE 5000 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["gunicorn", "--worker-class", "gevent", "--workers", "1", "--bind", "0.0.0.0:5000", "mimic.app:create_app()"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..df7d746 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,80 @@ +# --- Mimic backend Makefile ------------------------------------------------ +# Detects Docker / Podman automatically (NF-platform). + +PY ?= python3.12 +VENV ?= .venv +PIP := $(VENV)/bin/pip +PYTHON := $(VENV)/bin/python +PYTEST := $(VENV)/bin/pytest +RUFF := $(VENV)/bin/ruff +MYPY := $(VENV)/bin/mypy +ALEMBIC := $(VENV)/bin/alembic +FLASK := $(VENV)/bin/flask + +# Container runtime auto-detect: Docker (preferred) or Podman rootless. +CONTAINER ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) +COMPOSE ?= $(shell command -v docker-compose 2>/dev/null || echo "$(CONTAINER) compose") + +export FLASK_APP=mimic.app:create_app + +.PHONY: help install lint fmt typecheck test test-int test-cov run \ + db-up db-down db-migrate db-revision db-seed db-dump db-restore \ + build clean + +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +install: ## Create venv and install dev dependencies + $(PY) -m venv $(VENV) + $(PIP) install --upgrade pip wheel + $(PIP) install -e ".[dev]" + +lint: ## Ruff lint + $(RUFF) check src tests + +fmt: ## Ruff format + $(RUFF) format src tests + $(RUFF) check --fix src tests + +typecheck: ## Mypy strict + $(MYPY) src + +test: ## Unit tests (SQLite) + $(PYTEST) tests/unit -v + +test-int: ## Integration tests (testcontainers Postgres) + $(PYTEST) tests/integration -v -m integration + +test-cov: ## Unit + integration with coverage report + $(PYTEST) --cov=mimic --cov-report=term-missing --cov-report=html + +run: ## Run Flask dev server + $(FLASK) run --host 0.0.0.0 --port 5000 --debug + +db-up: ## Start Postgres dev container + $(COMPOSE) up -d postgres + +db-down: ## Stop Postgres dev container + $(COMPOSE) down + +db-migrate: ## Apply migrations + $(ALEMBIC) upgrade head + +db-revision: ## Generate migration (msg=...) + $(ALEMBIC) revision --autogenerate -m "$(msg)" + +db-seed: ## Seed local dev data (TBD sprint 1) + @echo "TBD sprint 1" + +db-dump: ## Manual DB dump (NF-state, R-O1) + $(VENV)/bin/mimic-cli db dump --out backups/mimic-$$(date +%Y%m%d-%H%M%S).sql + +db-restore: ## Restore from dump (file=...) + $(VENV)/bin/mimic-cli db restore --file $(file) + +build: ## Build wheel + $(PYTHON) -m build + +clean: + rm -rf $(VENV) .pytest_cache .mypy_cache .ruff_cache htmlcov dist build *.egg-info diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..62adec3 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,56 @@ +# Mimic — backend + +Sprint 0 skeleton. Python 3.12+ / Flask / SQLAlchemy 2 / Alembic / Pydantic 2. + +## Layout + +``` +backend/ +├── src/mimic/ +│ ├── app.py # Flask app factory + SocketIO init +│ ├── config.py # Pydantic Settings +│ ├── extensions.py # db, migrate, socketio, login_manager +│ ├── db/ +│ │ ├── models/ # SQLAlchemy 2 typed models +│ │ ├── repositories/ # data access per aggregate +│ │ └── migrations/ # Alembic +│ ├── schemas/ # Pydantic 2 DTOs +│ ├── api/ # Flask blueprints (REST) +│ ├── ws/ # Flask-SocketIO namespaces +│ ├── connectors/ # C2Connector ABC + payload mapping +│ ├── orchestrator/ # run state machine (stub in sprint 0) +│ ├── templating/ # Jinja2 sandbox + regex_extract +│ ├── audit/ # append-only writer + rotation +│ ├── reporting/ # WeasyPrint builder (stub in sprint 0) +│ ├── rbac/ # group-based permission matrix (F11) +│ ├── importers/ # ATR + C2 journal (stub in sprint 0) +│ └── cli/ # mimic-cli (click) +└── tests/ + ├── unit/ # SQLite, pure logic + └── integration/ # testcontainers Postgres +``` + +## Local dev + +```bash +make install # uv venv + pip install -e .[dev] +make db-up # docker compose up -d postgres +make db-migrate # alembic upgrade head +make run # flask run (debug) +make test # pytest unit +make test-int # pytest integration (testcontainers) +make lint # ruff + mypy strict +``` + +## What sprint 0 ships + +- Full §8 data model + Alembic initial migration (Postgres-specific constraints: audit_log write-only role, soc_session hash, c2_credential Fernet column). +- `C2Connector` ABC + dataclasses + `payload_type` enum + factory. **No real Mythic/Home implementation** (blocked on PR1/PR2). +- Jinja2 SandboxedEnvironment + `regex_extract` filter (re2). +- Local auth (bcrypt + Flask session) + group-based RBAC matching the F11 permission matrix. +- Flat CRUD on engagements / hosts / TTPs / scenarios. +- pytest baseline + testcontainers Postgres scaffolding. + +## Out of sprint 0 + +Orchestrator, WebSocket cockpit, real connectors, report generation, audit rotation. diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..350c202 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = src/mimic/db/migrations +prepend_sys_path = src +version_path_separator = os +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..4aacd6d --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,23 @@ +services: + postgres: + image: postgres:16-alpine + container_name: mimic-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-mimic} + POSTGRES_USER: ${POSTGRES_USER:-mimic_app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mimic_dev_password} + ports: + - "127.0.0.1:5432:5432" + volumes: + - mimic_pgdata:/var/lib/postgresql/data + - ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mimic_app} -d ${POSTGRES_DB:-mimic}"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + mimic_pgdata: + name: mimic_pgdata diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..0b38510 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,147 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "mimic" +version = "0.1.0a0" +description = "Mimic — internal BAS platform (sprint 0 skeleton)" +readme = "README.md" +requires-python = ">=3.12" +license = { text = "Proprietary" } +authors = [{ name = "RT" }] + +dependencies = [ + "flask>=3.0,<4.0", + "flask-socketio>=5.3,<6.0", + "flask-login>=0.6.3,<1.0", + "flask-migrate>=4.0,<5.0", + "sqlalchemy>=2.0,<3.0", + "alembic>=1.13,<2.0", + "psycopg[binary]>=3.1,<4.0", + "pydantic>=2.6,<3.0", + "pydantic-settings>=2.2,<3.0", + "python-json-logger>=2.0,<3.0", + "structlog>=24.1,<25.0", + "bcrypt>=4.1,<5.0", + "cryptography>=42.0,<43.0", + "jinja2>=3.1,<4.0", + "google-re2>=1.1,<2.0", + "click>=8.1,<9.0", + "gevent>=24.2,<25.0", + "gevent-websocket>=0.10,<1.0", + "httpx>=0.27,<1.0", + "weasyprint>=61.0,<62.0", + "authlib>=1.3,<2.0", + "pyyaml>=6.0,<7.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0,<9.0", + "pytest-cov>=5.0,<6.0", + "pytest-flask>=1.3,<2.0", + "pytest-mock>=3.12,<4.0", + "testcontainers[postgres]>=4.4,<5.0", + "ruff>=0.4,<1.0", + "mypy>=1.10,<2.0", + "types-pyyaml>=6.0,<7.0", + "freezegun>=1.5,<2.0", +] + +[project.scripts] +mimic-cli = "mimic.cli:cli" + +[tool.hatch.build.targets.wheel] +packages = ["src/mimic"] + +[tool.hatch.build.targets.sdist] +include = ["src/mimic", "README.md", "pyproject.toml"] + +# -- Ruff ------------------------------------------------------------------- + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "F", "W", # pycodestyle / pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "N", # pep8-naming + "S", # flake8-bandit (security) + "C4", # comprehensions + "DTZ", # datetime tz + "PIE", + "PT", # pytest + "RET", + "SIM", + "TID", + "PL", + "RUF", +] +ignore = [ + "PLR0913", # too many args (Flask handlers + DI) + "S101", # assert in tests +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101", "S105", "S106", "PLR2004"] +"src/mimic/db/migrations/**" = ["E501", "N999"] + +[tool.ruff.lint.isort] +known-first-party = ["mimic"] + +# -- Mypy ------------------------------------------------------------------- + +[tool.mypy] +python_version = "3.12" +strict = true +warn_unreachable = true +warn_unused_ignores = true +show_error_codes = true +plugins = ["pydantic.mypy"] +exclude = ["src/mimic/db/migrations/versions/"] + +[[tool.mypy.overrides]] +module = [ + "weasyprint.*", + "google.re2.*", + "re2", + "flask_socketio.*", + "flask_migrate.*", + "gevent.*", + "testcontainers.*", + "authlib.*", +] +ignore_missing_imports = true + +# -- Pytest ----------------------------------------------------------------- + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers --strict-config" +markers = [ + "integration: requires testcontainers Postgres", + "slow: long-running tests", +] +filterwarnings = ["error"] + +[tool.coverage.run] +branch = true +source = ["src/mimic"] +omit = ["src/mimic/db/migrations/*"] + +[tool.coverage.report] +fail_under = 70 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "\\.\\.\\.", +] diff --git a/backend/scripts/postgres-init/00-roles.sql b/backend/scripts/postgres-init/00-roles.sql new file mode 100644 index 0000000..e81da46 --- /dev/null +++ b/backend/scripts/postgres-init/00-roles.sql @@ -0,0 +1,20 @@ +-- Roles used by the application. +-- NF-AUDIT: audit_log must be append-only at the SQL level. The application +-- writes via mimic_audit_writer (INSERT only). The standard mimic_app role +-- has SELECT on audit_log but no UPDATE/DELETE. +-- +-- This file runs once at container init. Production deployment uses Ansible +-- to apply the same grants idempotently. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'mimic_audit_writer') THEN + CREATE ROLE mimic_audit_writer LOGIN PASSWORD 'CHANGE_ME'; + END IF; +END +$$; + +-- The mimic_app user is created by the official image entrypoint +-- via $POSTGRES_USER. We only need to make sure the audit writer exists. +-- Per-table grants are applied by the application's bootstrap step after +-- migrations land (so the audit_log table actually exists).