chore(backend): bootstrap Python 3.12+ project skeleton (B0.1)

- pyproject.toml with ruff + mypy strict + pytest + coverage >=70%
- Makefile with Docker/Podman auto-detect
- Multi-stage Dockerfile (python:3.12-slim-bookworm, non-root user)
- docker-compose.yml for Postgres dev DB
- alembic.ini wired to src/mimic/db/migrations
- scripts/postgres-init/00-roles.sql seeds the audit writer role
- .env.example documents every MIMIC_* var (no secrets committed)
This commit is contained in:
knacky
2026-05-21 20:32:29 +02:00
parent 2ead16114d
commit a93c959444
9 changed files with 448 additions and 0 deletions

23
backend/.env.example Normal file
View File

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

View File

60
backend/Dockerfile Normal file
View File

@@ -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()"]

80
backend/Makefile Normal file
View File

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

56
backend/README.md Normal file
View File

@@ -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.

39
backend/alembic.ini Normal file
View File

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

View File

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

147
backend/pyproject.toml Normal file
View File

@@ -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:",
"\\.\\.\\.",
]

View File

@@ -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).