feat(m0): bootstrap repo, design system, compose stack

- Repo scaffolding: .gitignore, .env.example, Makefile, docker-compose.yml,
  README.md, CHANGELOG.md, pre-commit config.
- Three-service stack: api (Flask 3), db (postgres:16-alpine), front (nginx
  serving the Vite bundle). Named volumes metamorph_db + metamorph_evidence.
- Backend skeleton: Flask app factory, JSON structured logging on stdout,
  GET /api/v1/health, multi-stage Dockerfile, pyproject.toml driven by uv,
  Pydantic Settings with secret guard rails (refuses to boot in non-dev with
  placeholders), APP_ENV gating.
- Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps
  design tokens from tasks/design.md, self-hosted JetBrains Mono / IBM Plex
  Sans via @fontsource, base UI primitives (Card/Tag/SectionHeader/FlowNode/
  Button), home page wired to /api/v1/health.
- Engine-agnostic Makefile: auto-detects docker or podman, picks the matching
  compose driver. Targets: up/down/build/rebuild/dev/lint/fmt/test/migrate/
  seed-mitre/print-install-token/e2e/inspect-health.
- Playwright suite: e2e/tests/m0-smoke.spec.ts (8 tests) + HTML + JUnit
  reports + traces on retry.
- Docs: tasks/spec.md (finalized after Q&A), tasks/design.md, tasks/todo.md
  (14 milestones), tasks/testing-m0.md, tasks/lessons.md.

DoD: make up + make health + make e2e all pass on podman 5.x (Fedora) and
docker. TLS terminated by external reverse proxy (spec §6 NF-network).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-11 06:16:00 +02:00
commit f1fdf27012
58 changed files with 5365 additions and 0 deletions

31
.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Copy to `.env` and fill in real values. Never commit `.env`.
# === Runtime mode ===
# `dev` allows the placeholder secrets below (only on a workstation).
# Anything else (`prod`, `staging`) forces strong values — the API refuses to boot otherwise.
APP_ENV=dev
# === Postgres ===
POSTGRES_DB=metamorph
POSTGRES_USER=metamorph
POSTGRES_PASSWORD=change-me-strong
POSTGRES_HOST=db
POSTGRES_PORT=5432
# === Backend (Flask API) ===
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET=change-me-to-a-long-random-string
LOG_LEVEL=INFO
# Comma-separated list of allowed origins for CORS (no trailing slash)
FRONT_ORIGIN=http://localhost:8080
# Where uploaded evidence files are stored inside the api container
EVIDENCE_DIR=/data/evidence
# === Frontend (build-time) ===
# Base URL the front uses to reach the API. In compose the nginx of the front
# proxies /api/* to the api service, so an empty/relative value is fine.
VITE_API_BASE_URL=/api/v1
# === Compose port mappings (host side) ===
HOST_API_PORT=8000
HOST_FRONT_PORT=8080

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Env / secrets
.env
.env.*
!.env.example
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.venv/
venv/
# Node
node_modules/
dist/
build/
.vite/
*.tsbuildinfo
# Build artifacts
*.exe
*.dll
*.bin
*.o
*.so
*.pyd
# Data & uploads (host-side mounts)
data/
backend/data/
# Editor / OS
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Logs
*.log
logs/

46
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,46 @@
# Run `pre-commit install` after cloning. CI should run `pre-commit run --all-files`.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-added-large-files
args: ["--maxkb=512"]
- id: detect-private-key
# Backend — ruff (lint + format)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.10
hooks:
- id: ruff
args: ["--fix"]
files: ^backend/.*\.py$
- id: ruff-format
files: ^backend/.*\.py$
# Frontend — eslint + tsc via local hooks (must be run from frontend/)
- repo: local
hooks:
- id: eslint
name: eslint (frontend)
entry: bash -c 'cd frontend && npm run lint'
language: system
files: ^frontend/.*\.(ts|tsx)$
pass_filenames: false
- id: tsc-noemit
name: tsc --noEmit (frontend)
entry: bash -c 'cd frontend && npm run typecheck'
language: system
files: ^frontend/.*\.(ts|tsx)$
pass_filenames: false
- id: prettier
name: prettier --check (frontend)
entry: bash -c 'cd frontend && npm run format:check'
language: system
files: ^frontend/.*\.(ts|tsx|css|json|html)$
pass_filenames: false

150
CHANGELOG.md Normal file
View File

@@ -0,0 +1,150 @@
# Changelog
All notable changes to this project will be documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · [Conventional Commits](https://www.conventionalcommits.org/).
## [Unreleased]
### Added — M3 (RBAC: groups, permissions, users)
- **Permission catalogue** (`app/services/permissions_seed.py`): 31 atomic codes across 10 families (`user`, `group`, `invitation`, `test_template`, `scenario_template`, `mission`, `detection_level`, `setting`, `mitre.sync`). Seeded at boot **and** after `/setup` to handle a freshly truncated DB. Idempotent + additive on system groups (never removes a perm).
- **Default group bindings**: `admin` = all 31 codes; `redteam` = 8 (catalogue read + mission.{read,create,update,archive,write_red_fields} + detection_level.read); `blueteam` = 5 (catalogue read + mission.{read,write_blue_fields} + detection_level.read).
- **Users admin service + API** (`app/services/users.py`, `app/api/users.py`): list (q + is_active filter + pagination), get, patch (display_name/locale/is_active), soft-delete, set groups. Last-admin protection on update/delete/group-strip.
- **Groups admin service + API** (`app/services/groups.py`, `app/api/groups.py`): full CRUD with system-group protection (no rename, no delete), `PUT /groups/{id}/permissions` for the bindings. Admin system group's perm set is locked to "every perm" (preserves the bypass invariant).
- **Permissions read-only API** (`app/api/permissions.py`): `GET /permissions` returns the catalogue (admin or `group.read` holders).
- **Frontend admin pages** (`frontend/src/pages/Admin{Users,Groups,Invitations}Page.tsx`): list + edit modals using TanStack Query mutations, multi-select for perms grouped by family, copy-once invitation URL display.
- **Frontend chrome** (`Layout.tsx` + `RequireAdmin.tsx`): admin nav links shown only when `is_admin === true`; direct navigation to `/admin/*` by non-admins redirects to `/`. Server remains the arbiter.
- **`/diag/reset` now clears the rate-limit counters** so the Playwright suite can iterate without hitting `10/min/IP` budgets across spec files. Gated to non-prod environments only.
- **Testing**:
- `tests/test_rbac.py`**15 pytest integration tests** (39 backend total).
- `e2e/tests/m3-rbac.spec.ts`**8 Playwright tests** covering DoD §10 #2/#3 (28 e2e total).
- `tasks/testing-m3.md` — manual + automated procedure.
- **Frontend api helpers**: `apiPatch`, `apiPut`, `apiDelete` added to `frontend/src/lib/api.ts`.
### Fixed (post-M3 spec-review pass)
- **Rate-limit scope clarified**: `app/core/rate_limit.py` now enables the limiter for `APP_ENV in ("prod", "staging")` instead of `prod` only — a public staging deployment without auth limits would be surprising. Dev/test stay unthrottled for Playwright ergonomics. Spec §6 NF-security applies to operator-facing deployments.
- **Admin perm invariant**: `set_group_permissions` refuses to alter the admin system group's perm set to anything other than the full catalogue (`SystemGroupProtected` → 409). The decorator bypass relies on `is_admin = "admin" in group_names`, but a future refactor could move to a perm-based check, so we keep the invariant.
- **LogRecord field collision**: `log.info("...", extra={"name": g.name})` raised `KeyError: "Attempt to overwrite 'name' in LogRecord"` because Python's logger reserves `name`. Renamed to `group_name`. Audited all other `extra=` payloads in `app/api/`+`app/services/` for the same trap.
### Validated end-to-end (M3 DoD)
- `make clean && make up && make migrate` → boot logs show `metamorph.permissions.seeded {perms_created: 31, perms_total: 31, bindings: {admin: 31, redteam: 8, blueteam: 5}}`.
- `make test-api`**39 pytest pass** (1 health + 8 schema + 15 auth + 15 RBAC) in ~4 s.
- `make e2e`**28 Playwright pass** (8 M0 + 4 M1 + 8 M2 + 8 M3) in ~16 s.
- Spec-reviewer pass: PASS verdict, 2 minor fixes applied (above), 2 anticipations noted for M12/M14 (no current action).
### Added — M2 (Auth, bootstrap, invitations)
- **Crypto plumbing**: `app.core.security` (Argon2id `time_cost=2 memory_cost=64MiB parallelism=2`, opaque-token SHA-256 helpers), `app.core.jwt_tokens` (HS256, claims `iss/sub/type/jti/iat/exp`, access 1h / refresh 30d).
- **Auth services** (`app.services.auth`): login, refresh **with token rotation + reuse-detection chain revoke**, logout (idempotent), change_password (forces logout-all).
- **Invitation services** (`app.services.invitations`): create, preview, accept, revoke. Token persisted only as SHA-256, default 7-day TTL.
- **Bootstrap** (`app.services.bootstrap` + `app.core.install_token`): seeds 3 system groups (`admin`/`redteam`/`blueteam`), mints a one-shot install token at first boot when `users` is empty, logs a banner with the raw token. CLI `flask --app app.cli metamorph print-install-token [--force]`.
- **Auth middleware** (`app.core.auth_decorators`): `@require_auth` populates `g.current_user`; `@require_perm("...")` checks atomic permissions; admin group bypasses the check (atomic perms land in M3).
- **API endpoints**:
- `POST /api/v1/setup` (consume install token, create 1st admin) + `GET /api/v1/setup` (status).
- `POST /api/v1/auth/login` + `POST /auth/refresh` + `POST /auth/logout` + `GET /auth/me` + `POST /auth/change-password`.
- `POST /api/v1/invitations` (admin) + `GET /invitations` + `GET /invitations/preview/<token>` + `POST /invitations/accept/<token>` + `POST /invitations/<id>/revoke`.
- `POST /api/v1/diag/reset` (test-only kill switch — wipes auth tables + mints fresh install token; only available in `dev`/`test`).
- **Rate limiting** (`flask-limiter`): 10/min/IP on `/auth/login`, `/auth/refresh`; 5/min on `/auth/change-password` and `/setup`; 1020/min on invitation endpoints. Globally disabled when `APP_ENV=test`.
- **Refresh cookie** `metamorph_refresh`: HttpOnly + Secure + SameSite=Strict + Path=`/api/v1/auth/`.
- **Frontend auth state** (`frontend/src/lib/{api,auth}.ts`): access token in module memory, refresh in cookie, automatic 401-retry via `/auth/refresh` with reentrancy guard. `useAuth()` hook + `<RequireAuth>` route guard.
- **Frontend pages**: `/login`, `/setup`, `/register?token=…`, `/profile` (with change-password form), all in RTOps design. Protected layout: nav shows email + Logout when authenticated, Login + Setup links when not.
- **Frontend deps**: `@tanstack/react-query`, `react-router-dom`. Tanstack provider in `App.tsx` (will carry actual queries from M3+).
- **Email validation** (`app.api._validation.Email`): permissive RFC-shape regex that accepts internal TLDs (`.local`, `.corp`) — `pydantic.EmailStr` was too strict for red-team labs.
- **Testing**:
- `tests/test_auth_flow.py`**15 pytest integration tests** (24 backend total with M0/M1).
- `e2e/tests/m2-auth.spec.ts`**8 Playwright tests** covering setup → login → me → invitation → register → 2nd login → RBAC 403 → refresh rotation → logout (20 e2e total).
- `tasks/testing-m2.md` — manual + automated procedure.
### Fixed (post-M2 spec-review pass)
- **Refresh cookie `Secure=True`** unconditionally (`backend/app/api/auth.py`). Modern browsers treat `localhost` as a secure context, so dev/test still works. Closes the silent-degradation found by the reviewer.
- **`/auth/refresh` rate-limit lowered to 10/min/IP** (`backend/app/api/auth.py`) to match spec §M2 ("10 req/min/IP on `/auth/*`").
- `/diag/reset` kept allowed in `dev` *and* `test` (a `make e2e` against a `make up` dev stack must be able to reset). Added a WARNING log when triggered in `dev` and a clear docstring; production envs (`prod`/`staging`) remain locked out.
### Known scope-creep (intentional, not retracted)
- Rate-limits on `/setup` (5/min), `/invitations/preview` (20/min), `/invitations/accept` (10/min) and `/auth/change-password` (5/min) were added in M2 even though §M2 only mandated `/auth/*`. Defensible (these are abuse-attractor endpoints), and noted here so M14 doesn't double-spec them.
### Added — M1 (DB schema & migrations)
- **23 tables** + `alembic_version` covering auth/RBAC (8), MITRE (4), templates (4), missions (6), evidence (1), settings/detection-levels (2), notifications (1).
- SQLAlchemy 2.x declarative models with Mapped[]/mapped_column(), grouped under `backend/app/models/{auth,mitre,template,mission,evidence,setting,notification}.py`.
- Alembic init: `alembic.ini`, `alembic/env.py` reading `app.core.config.settings.database_url`, `alembic/script.py.mako`, naming convention `pk_/fk_/ck_/uq_/ix_` enforced via `MetaData(naming_convention=...)` on `app.db.base.Base`.
- Reusable mixins in `app.db.mixins`: `UuidPkMixin` (uuid4 server-side), `TimestampMixin` (created_at/updated_at, server-default + onupdate), `SoftDeleteMixin` (deleted_at, no auto-injected index — declared explicitly per table to avoid mixin-vs-class `__table_args__` clobbering).
- Postgres-specific features used: `JSONB` for `settings.value` and `notifications.payload`; native `Uuid` columns; partial indexes (`WHERE deleted_at IS NULL` on 9 tables; `WHERE read_at IS NULL` on `notifications`); CHECK constraints for status/state/opsec_level/mitre_kind enums; `exactly_one_mitre_fk` CHECK on `test_template_mitre_tags`.
- **`mission_test_mitre_tags` deliberately denormalised** (no FK to `mitre_*` tables): copies `mitre_external_id`, `mitre_name`, `mitre_url` at tag time so a later MITRE re-sync that drops an entry cannot purge a mission's tags. Companion `test_template_mitre_tags` keeps FKs since templates are editable. (Spec §11 risk addressed.)
- Backend `pyproject.toml` deps: SQLAlchemy ≥2, Alembic ≥1.13, psycopg[binary] ≥3.1.
- New Makefile targets: `migrate`, `migrate-down`, `migrate-revision MSG=…`, `migrate-status`. The Dockerfile now ships `alembic.ini` + `alembic/` so the api container can run migrations directly.
- **Test stage in `backend/Dockerfile`** (`--target test`): runtime image + dev extras + `tests/` dir. New `make test-api` target spins an ephemeral container against the live DB on the compose network. Backend tests no longer require any local Python toolchain.
- `tests/test_schema.py` (8 integration tests + the existing M0 health test = 9 total): expected tables, expected timestamp/soft-delete columns, partial-index presence, expected FK pairs, expected CHECK constraints, alembic-at-head, and a negative INSERT proving the `exactly_one_mitre_fk` CHECK fires.
- `tasks/testing-m1.md` — manual + automated verification procedure.
### Fixed (post-M1 spec-review pass)
- Soft delete now consistent across snapshot-bearing tables: `mission_scenarios`, `mission_tests`, `mission_categories` gained `SoftDeleteMixin` + their `ix_<table>_active` partial index (M12 trash bin depends on this).
- `evidence_files` gained `TimestampMixin` (`created_at`/`updated_at`) on top of the domain `uploaded_at` (audit minimal everywhere, per M1 brief).
- `mission_members` gained `TimestampMixin`, replacing the bespoke `added_at` column.
- `scenario_template_tests` PK refactored to a UUID + `UNIQUE(scenario_template_id, position)` so the same test can appear at multiple positions in a scenario (chained operations).
- `SoftDeleteMixin.__table_args__` removed (silently clobbered by class `__table_args__`); each soft-delete table now declares `ix_<table>_active` explicitly. Documented in the mixin's docstring.
- `mission_test_mitre_tags` schema redesigned to denormalise MITRE labels (see "Added" entry above).
- Migration 0001 regenerated end-to-end after these fixes — `24765a5014b6` is the new HEAD.
### Validated end-to-end (M1 DoD)
- `make clean && make up && make migrate` from a vide DB → 27 tables, 32 FK, 9 CHECK, 14 UQ, 12 partial indexes.
- `make test-api`**9 pytest pass** (1 health + 8 schema integration) in <1 s.
- `make e2e`**12 Playwright pass** (8 M0 smoke + 4 M1 db visibility) in 3 s.
### Added (M1 visibility)
- New API endpoint `GET /api/v1/diag/db` exposes `alembic_revision` (short-hashable) and the public-schema `table_count`. Returns 503 with `{"reachable": false}` when Postgres is down.
- New `Database` card on the SPA home page consumes that endpoint, renders the revision short-hash and the count next to the existing `API` and `Roadmap` cards.
- Footer updated to `M0 bootstrap · M1 db schema`. Roadmap card now points to `M2 — Auth + JWT`.
- New e2e suite `e2e/tests/m1-db.spec.ts` (4 tests) covers the diag endpoint contract, the Database card rendering, and the footer/roadmap labels.
### Added — M0 (bootstrap)
- Repo scaffolding: `.gitignore`, `.env.example`, `Makefile`, `docker-compose.yml`, `README.md`, `CHANGELOG.md`.
- `docker-compose.yml` with three services: `db` (postgres:16-alpine, no host port), `api` (Flask 3, port 8000), `front` (nginx serving the Vite bundle, port 80).
- Named volumes `metamorph_db` and `metamorph_evidence` for data persistence.
- Backend skeleton: Flask app factory, JSON structured logging on stdout, `GET /api/v1/health` endpoint, multi-stage Dockerfile, `pyproject.toml` driven by `uv`.
- Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps design tokens (`tasks/design.md`) translated into `tailwind.config.ts`, base UI primitives (`Card`, `Tag`, `SectionHeader`, `FlowNode`, `Button`), home page wired to `/api/v1/health`.
- Multi-stage frontend Dockerfile that builds the bundle and serves it via nginx, proxying `/api/*` to the api container.
- Pre-commit hook config: `ruff` for backend, `eslint` + `tsc --noEmit` for frontend.
### Validated
- `docker compose config` parses (validated via `pyyaml` since Docker is not installed in the dev shell).
- Every env var referenced by the compose file is documented in `.env.example`.
- All Python source files parse cleanly (`ast.parse`).
- All TS/JSON config files parse cleanly.
### Notes
- TLS termination is delegated to an external reverse proxy (per spec §6 NF-network). The compose stack exposes plain HTTP on `HOST_FRONT_PORT` (8080) and `HOST_API_PORT` (8000).
- The first-admin bootstrap token (M2) will be printed to the api container's stdout on first boot when the `users` table is empty.
- `tasks/spec.md` and `tasks/todo.md` remain authoritative; update them before changing scope.
### Fixed (M0 DoD validation pass on real podman)
- **FQDN image references** in `docker-compose.yml`, `backend/Dockerfile`, `frontend/Dockerfile`. Podman on Fedora enforces `short-name-mode=enforcing` for pulls (no TTY ⇒ no prompt ⇒ failure). Replaced `postgres:16-alpine` / `python:3.12-slim` / `node:20-alpine` / `nginx:1.27-alpine` with their `docker.io/library/…` qualified equivalents. Docker accepts the same prefix transparently.
- **`*.md` removed from `backend/.dockerignore` and `frontend/.dockerignore`**: `pyproject.toml` declared `readme = "README.md"`, but the file was being filtered out of the build context, so `hatchling.build.build_wheel` raised `OSError: Readme file does not exist: README.md`. Also removed the `readme` field itself from `pyproject.toml` to decouple the build from the doc.
- **`Card.tsx` type clash**: `CardProps extends HTMLAttributes<HTMLDivElement>` redefined `title` as `ReactNode`, but the native `title` is `string`. `tsc -b` failed with TS2430 during `vite build`. Switched to `Omit<HTMLAttributes<HTMLDivElement>, 'title'>`.
- **Explicit healthchecks added to compose `api` and `front`**: podman-compose 1.x doesn't surface healthchecks declared only in the `Dockerfile` via `inspect`. Mirroring them in `docker-compose.yml` makes `make inspect-health` actually see `healthy/unhealthy/starting` on every engine.
- **Suppressed `podman compose` external-provider banner** via `PODMAN_COMPOSE_WARNING_LOGS=false` exported from the Makefile.
### Validated end-to-end on podman 5.x (Fedora 43)
- `make up` → 3 containers, all 3 healthy after start_period.
- `make health``{"status":"ok","version":"0.1.0"}` via the front nginx proxy (port 8080) and direct API (port 8000).
- `make logs-api` → JSON-structured lines on stdout (`ts`, `level`, `logger`, `message`, custom fields).
- `make e2e`**8/8 Playwright tests pass** in 2.5 s. Reports: `e2e/playwright-report/index.html` (529 KB, autoportant) + `junit.xml` (`tests=8 failures=0 skipped=0 errors=0`).
### Added (engine portability)
- Makefile auto-detects **docker** or **podman** at runtime and selects the matching compose driver (`docker compose`, `podman compose`, or legacy `podman-compose`). Override via `ENGINE=…` and/or `COMPOSE="…"`.
- New targets: `engine` (print detected runtime), `volumes` (list project-named volumes), `inspect-health` (health status of all 3 containers), `logs-api` (tail just the api), `health` (single curl probe). All engine-agnostic.
- `make help` now prints the active engine + compose driver in its footer.
- `tasks/testing-m0.md` and `README.md` rewritten to be engine-agnostic — raw `docker logs` / `docker volume ls` / `docker inspect` calls replaced with the new make targets.
### Added (M0 testing)
- `e2e/` Playwright project with chromium, HTML + JUnit XML reporters, traces / screenshots / videos kept on retry. Reports land in `e2e/playwright-report/`.
- `e2e/tests/m0-smoke.spec.ts` — 8 smoke tests covering the front rendering, the API proxy, the design tokens, the absence of any runtime CDN traffic (spec §7), and the CORS contract.
- Makefile targets `e2e-install`, `e2e`, `e2e-report`, `e2e-up`, `wait-healthy`.
- `tasks/testing-m0.md` — step-by-step manual + automated verification procedure for M0.
- Convention added to `tasks/todo.md`: every milestone N delivers `tasks/testing-m<N>.md` + at least one `e2e/tests/m<N>-*.spec.ts`, and the spec-reviewer subagent runs before marking the milestone done.
### Fixed (post-M0 spec-review pass)
- `.pre-commit-config.yaml` added at repo root: ruff + ruff-format on backend, eslint + tsc --noEmit + prettier --check on frontend, plus baseline whitespace/JSON/private-key checks. Documented `pre-commit install` in `README.md`.
- Self-hosted webfonts via `@fontsource/jetbrains-mono` and `@fontsource/ibm-plex-sans` (imported in `frontend/src/index.css`); dropped the Google Fonts `<link>` from `frontend/index.html` to honor spec §7 ("no runtime CDN").
- Refuse-to-boot guard in `backend/app/core/config.py`: when `APP_ENV != "dev"`, defaults / placeholders for `JWT_SECRET` and `POSTGRES_PASSWORD` raise at startup. New `APP_ENV` env var documented in `.env.example`, `README.md`, and `docker-compose.yml`.
- `make dev` now runs `dev-api` and `dev-front` in parallel via `make -j2` instead of just printing a hint.
- Removed dead `database_url` property from `Settings` (will be reintroduced in M1 with the SQLAlchemy/Alembic stack).
- Pinned Node engines to `>=20` in `frontend/package.json`.
- Reconciled M0 DoD wording in `tasks/todo.md` (HTTP via `HOST_FRONT_PORT`, with explicit note that prod TLS is external).
- Documented the `2xs/3xs/4xs` font-size aliases in `frontend/tailwind.config.ts` against the design.md §3 scale.

214
Makefile Normal file
View File

@@ -0,0 +1,214 @@
.DEFAULT_GOAL := help
SHELL := /bin/bash
# Load .env if present so targets can use the same variables as compose.
ifneq (,$(wildcard ./.env))
include .env
export
endif
# === Container engine detection (docker OR podman) ============================
#
# Auto-detects on PATH, with docker preferred when both are installed.
# Override either variable from the environment or the command line:
# make up ENGINE=podman
# make up COMPOSE="podman compose"
#
ENGINE ?= $(shell \
if command -v docker >/dev/null 2>&1; then echo docker; \
elif command -v podman >/dev/null 2>&1; then echo podman; \
fi)
ifeq ($(strip $(ENGINE)),)
$(error Neither docker nor podman found in PATH. Install one, or set ENGINE=...)
endif
# Pick the right compose driver based on the chosen engine.
# - docker → "docker compose" (compose v2 plugin)
# - podman 4.0+ → "podman compose"
# - older podman → "podman-compose" (legacy Python wrapper)
ifndef COMPOSE
ifeq ($(ENGINE),docker)
COMPOSE := docker compose
else
COMPOSE := $(shell \
if podman compose version >/dev/null 2>&1; then echo "podman compose"; \
elif command -v podman-compose >/dev/null 2>&1; then echo "podman-compose"; \
else echo "podman compose"; fi)
endif
endif
# Project name is mostly used to look up volumes / containers via raw engine calls.
PROJECT ?= metamorph
# Suppress the noisy `>>>> Executing external compose provider …` banner that
# `podman compose` emits on every invocation (harmless, but spammy in logs).
export PODMAN_COMPOSE_WARNING_LOGS = false
.PHONY: help env engine up down build rebuild logs logs-api ps health shell-api shell-db psql \
dev dev-api dev-front lint lint-api lint-front fmt test test-api test-front \
e2e e2e-install e2e-report e2e-up wait-healthy \
migrate migrate-down migrate-revision migrate-status \
seed-mitre print-install-token print-install-token-force \
volumes inspect-health clean
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_0-9-]+:.*##/ {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@printf "\n Container engine in use: \033[33m%s\033[0m | compose: \033[33m%s\033[0m\n" "$(ENGINE)" "$(COMPOSE)"
engine: ## Print the detected container engine and compose driver
@echo "ENGINE=$(ENGINE)"
@echo "COMPOSE=$(COMPOSE)"
env: ## Bootstrap a local .env from .env.example if missing
@test -f .env || (cp .env.example .env && echo "Created .env — edit it before 'make up'")
# === Compose lifecycle ========================================================
up: env ## Build (if needed) and start all services
$(COMPOSE) up -d --build
down: ## Stop and remove containers (keep volumes)
$(COMPOSE) down
build: ## Build images without starting
$(COMPOSE) build
rebuild: ## Force rebuild without cache
$(COMPOSE) build --no-cache
logs: ## Tail logs from all services
$(COMPOSE) logs -f --tail=200
logs-api: ## Tail only the api container logs (useful to inspect JSON log lines)
$(COMPOSE) logs -f --tail=200 api
ps: ## List running services
$(COMPOSE) ps
shell-api: ## Shell into the api container
$(COMPOSE) exec api bash
shell-db: ## Shell into the db container
$(COMPOSE) exec db sh
psql: ## Open psql in the db container
$(COMPOSE) exec db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB)
# === Container introspection (engine-agnostic) ================================
volumes: ## List the named volumes created by this project
@$(ENGINE) volume ls --filter "name=$(PROJECT)_"
inspect-health: ## Print the health status of every container in the project
@for c in $(PROJECT)-db $(PROJECT)-api $(PROJECT)-front; do \
printf "%-30s " "$$c"; \
$(ENGINE) inspect --format '{{.State.Health.Status}}' "$$c" 2>/dev/null || echo "(no-healthcheck or stopped)"; \
done
# === Local dev (no container) =================================================
dev: ## Run api + front locally in parallel (Ctrl-C stops both)
@$(MAKE) -j2 --no-print-directory dev-api dev-front
dev-api: ## Run Flask in dev mode
cd backend && APP_ENV=dev uv run flask --app app.main run --debug --host 0.0.0.0 --port 8000
dev-front: ## Run Vite dev server
cd frontend && npm run dev
# === Quality ==================================================================
lint: lint-api lint-front ## Lint everything
lint-api:
cd backend && uv run ruff check . && uv run ruff format --check .
lint-front:
cd frontend && npm run lint && npm run typecheck
fmt: ## Auto-format
cd backend && uv run ruff format .
cd frontend && npm run format
test: test-api test-front ## Run all tests
test-api: ## Run backend pytest in an ephemeral container against the live DB
@echo "Building backend test image (target: test)…"
@$(ENGINE) build -q --target test -t metamorph-api-test ./backend > /dev/null
@echo "Running pytest…"
$(ENGINE) run --rm \
--network $(PROJECT)_metamorph \
-e APP_ENV=test \
-e POSTGRES_DB=$(POSTGRES_DB) \
-e POSTGRES_USER=$(POSTGRES_USER) \
-e POSTGRES_PASSWORD=$(POSTGRES_PASSWORD) \
-e POSTGRES_HOST=db \
-e POSTGRES_PORT=5432 \
-e JWT_SECRET=test-only-secret-not-checked-in-this-mode \
-e LOG_LEVEL=WARNING \
-e FRONT_ORIGIN=http://localhost:8080 \
metamorph-api-test
test-front:
cd frontend && npm test --if-present
# === End-to-end tests (Playwright) ============================================
e2e-install: ## Install Playwright deps + chromium browser (use sudo on Debian/Ubuntu)
cd e2e && npm install && npx playwright install --with-deps chromium
e2e: wait-healthy ## Run the e2e suite against the running stack
cd e2e && BASE_URL=http://localhost:$(or $(HOST_FRONT_PORT),8080) npm test
e2e-report: ## Open the latest Playwright HTML report
cd e2e && npx playwright show-report
e2e-up: ## Bring the stack up, wait healthy, then run e2e
$(MAKE) up
$(MAKE) e2e
health: ## Curl the health endpoint via the front nginx (one shot)
@curl -sSf "http://localhost:$(or $(HOST_FRONT_PORT),8080)/api/v1/health" \
&& echo "" \
|| (echo " unreachable — is 'make up' done?"; exit 1)
wait-healthy: ## Wait until the front+api are reachable (60s timeout)
@port=$(or $(HOST_FRONT_PORT),8080); \
echo "Waiting for http://localhost:$$port/api/v1/health …"; \
for i in $$(seq 1 30); do \
if curl -sf "http://localhost:$$port/api/v1/health" > /dev/null; then \
echo " ready after $$((i*2))s"; exit 0; \
fi; \
sleep 2; \
done; \
echo " timeout after 60s — check 'make ps' and 'make logs'"; exit 1
# === App-specific commands (placeholders for later milestones) ================
migrate: ## Apply DB migrations (alembic upgrade head, runs inside the api container)
$(COMPOSE) exec api alembic upgrade head
migrate-down: ## Roll back the latest migration
$(COMPOSE) exec api alembic downgrade -1
migrate-revision: ## Generate a new autogenerated migration: make migrate-revision MSG="my message"
@test -n "$(MSG)" || (echo "Usage: make migrate-revision MSG=\"short description\""; exit 1)
$(COMPOSE) exec api alembic revision --autogenerate -m "$(MSG)"
migrate-status: ## Show current revision and any pending migrations
$(COMPOSE) exec api alembic current
@echo "---"
$(COMPOSE) exec api alembic heads
seed-mitre: ## Seed MITRE ATT&CK Enterprise dataset (M4)
$(COMPOSE) exec api flask --app app.cli metamorph seed-mitre
print-install-token: ## Print the bootstrap install token (M2)
$(COMPOSE) exec api flask --app app.cli metamorph print-install-token
print-install-token-force: ## Force-mint a fresh install token (M2, --force)
$(COMPOSE) exec api flask --app app.cli metamorph print-install-token --force
clean: ## Remove containers, networks AND volumes (DESTRUCTIVE)
$(COMPOSE) down -v

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# Metamorph
Collaborative purple-team platform. Red team logs the tests they execute (procedure, command, timestamp); blue team annotates each test with detection evidence (alerts, logs, files). At the end of an engagement, Metamorph generates a standalone reveal.js slide deck classified by MITRE ATT&CK tactic.
> **Status**: M0 (bootstrap). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan.
## Stack
- **Backend**: Python 3.12, Flask 3, SQLAlchemy 2 + Alembic (M1+), PostgreSQL 16.
- **Frontend**: React 18 + TypeScript + Vite + TailwindCSS (RTOps design tokens, see `tasks/design.md`).
- **Auth (M2+)**: JWT access (1h) + refresh (30d), Argon2id, invite-link enrollment.
- **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production.
## Quickstart
Works with **Docker** *or* **Podman**. The Makefile auto-detects the available engine and picks the matching compose driver (`docker compose`, `podman compose`, or `podman-compose`).
Requires one of:
- Docker Engine 24+ with the Compose v2 plugin, **or**
- Podman 4.0+ with `podman compose` (or the legacy `podman-compose` ≥ 1.0.6)
```bash
git clone <this repo>
cd Metamorph
make engine # confirm which engine the Makefile picked up
make env # creates .env from .env.example
$EDITOR .env # set strong values for POSTGRES_PASSWORD and JWT_SECRET
make up # builds and starts api + db + front
make logs # tail logs
```
Override the auto-detection if you have both engines installed:
```bash
make up ENGINE=podman # force podman + auto-pick its compose driver
make up ENGINE=docker COMPOSE="docker compose"
COMPOSE=podman-compose make up # force the legacy wrapper specifically
```
Then:
- Front: <http://localhost:8080>
- API health: <http://localhost:8080/api/v1/health> (proxied) or <http://localhost:8000/api/v1/health>
To stop:
```bash
make down # keep volumes
make clean # also drop volumes (DESTRUCTIVE)
```
## Local dev (no Docker)
Requires:
- [uv](https://github.com/astral-sh/uv) for Python deps
- Node.js 20+ and `npm`
- A reachable Postgres (or `make up db` to run only the db container)
```bash
make dev-api # in one terminal
make dev-front # in another
```
## Environment variables
See `.env.example`. The most important ones:
| Variable | Purpose |
|--------------------|------------------------------------------------------|
| `APP_ENV` | `dev` allows placeholder secrets; anything else (prod/staging) refuses to boot with defaults |
| `POSTGRES_*` | DB credentials (used by `db` and `api`) |
| `JWT_SECRET` | HS256 signing key — generate 64+ random bytes (`python -c "import secrets; print(secrets.token_urlsafe(64))"`) |
| `LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
| `FRONT_ORIGIN` | Allowed CORS origin for the SPA |
| `EVIDENCE_DIR` | Path inside the api container where uploads land |
| `HOST_API_PORT` | Host port mapped to the api (default 8000) |
| `HOST_FRONT_PORT` | Host port mapped to the front nginx (default 8080) |
## Testing
- **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m0.md) (currently `testing-m0.md`).
- **Backend unit tests**: `make test-api`
- **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`.
## Pre-commit hooks
After cloning, install hooks once:
```bash
pipx install pre-commit # or: pip install --user pre-commit
pre-commit install
pre-commit run --all-files # initial sweep
```
The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit` / `prettier --check` on the frontend (see `.pre-commit-config.yaml`).
## Project layout
```
.
├── backend/ # Flask API
│ └── app/
│ ├── api/ # HTTP layer (blueprints)
│ ├── core/ # config, logging, errors
│ ├── db/ # SQLAlchemy session, migrations (M1+)
│ ├── models/ # ORM models (M1+)
│ ├── services/ # domain logic (M2+)
│ └── i18n/ # message catalogs (M13)
├── frontend/ # Vite + React + TS + Tailwind
│ └── src/components/ui/ # RTOps design system primitives
├── tasks/
│ ├── spec.md # source of truth for requirements
│ ├── design.md # RTOps design system
│ ├── todo.md # milestone plan
│ └── lessons.md # session retrospectives
├── docker-compose.yml
├── Makefile
└── CHANGELOG.md
```
## Roadmap
See `tasks/todo.md`. Current milestone: **M0 — bootstrap**.
## License
TBD.

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.venv/
.env
.env.*
!.env.example

85
backend/Dockerfile Normal file
View File

@@ -0,0 +1,85 @@
# syntax=docker/dockerfile:1.7
# === Stage 1: install deps with uv ===
FROM docker.io/library/python:3.12-slim AS deps
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install uv (fast, reproducible Python package manager)
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml ./
# Resolve & install deps into a dedicated venv. After running `uv lock` locally,
# switch this to `uv sync --frozen --no-dev` for fully reproducible builds.
RUN uv venv /opt/venv \
&& uv pip install --python /opt/venv/bin/python --no-cache .
# === Stage 2: runtime ===
FROM docker.io/library/python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# Non-root user
RUN groupadd --gid 10001 metamorph \
&& useradd --uid 10001 --gid metamorph --shell /usr/sbin/nologin --create-home metamorph \
&& mkdir -p /data/evidence \
&& chown -R metamorph:metamorph /data
COPY --from=deps /opt/venv /opt/venv
WORKDIR /app
COPY --chown=metamorph:metamorph app ./app
COPY --chown=metamorph:metamorph alembic ./alembic
COPY --chown=metamorph:metamorph alembic.ini pyproject.toml ./
USER metamorph
EXPOSE 8000
# Healthcheck hits the local API.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; \
sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)"
CMD ["gunicorn", "app.main:app", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--threads", "4", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"--log-level", "info"]
# === Stage 3: test image — runtime deps + dev extras + tests dir ===
# Built only when explicitly targeted (`build --target test`). Not used in prod.
FROM docker.io/library/python:3.12-slim AS test
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
COPY --from=deps /opt/venv /opt/venv
WORKDIR /app
COPY pyproject.toml ./
# Install the dev extras (pytest, ruff, httpx) on top of the runtime venv.
RUN uv pip install --python /opt/venv/bin/python --no-cache ".[dev]"
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
COPY tests ./tests
CMD ["python", "-m", "pytest", "tests", "-v"]

39
backend/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Metamorph backend
Flask 3 API. See repo root `README.md` for the big picture.
## Layout
```
app/
├── api/ # HTTP layer (blueprints), versioned under /api/v1
├── core/ # config (env-driven), structured logging
├── db/ # SQLAlchemy session + Alembic (M1+)
├── models/ # ORM models (M1+)
├── services/ # domain logic (M2+)
└── i18n/ # message catalogs (M13)
tests/ # pytest
```
## Local dev
Requires [uv](https://github.com/astral-sh/uv) and a reachable Postgres (M1+; not needed yet for `/health`).
```bash
uv sync # install deps from pyproject.toml
uv run flask --app app.main run --debug --port 8000
curl http://localhost:8000/api/v1/health
```
## Tests
```bash
uv run pytest
```
## Lint
```bash
uv run ruff check .
uv run ruff format .
```

3
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Metamorph backend API package."""
__version__ = "0.1.0"

View File

14
backend/app/api/health.py Normal file
View File

@@ -0,0 +1,14 @@
"""Health endpoint — no DB dependency, used by orchestrators and the SPA."""
from __future__ import annotations
from flask import Blueprint, jsonify
from app import __version__
bp = Blueprint("health", __name__)
@bp.get("/health")
def health():
return jsonify({"status": "ok", "version": __version__})

24
backend/app/api/v1.py Normal file
View File

@@ -0,0 +1,24 @@
"""Aggregate v1 blueprint. Future blueprints (missions, ...) register here."""
from __future__ import annotations
from flask import Blueprint
from app.api.auth import bp as auth_bp
from app.api.diag import bp as diag_bp
from app.api.groups import bp as groups_bp
from app.api.health import bp as health_bp
from app.api.invitations import bp as invitations_bp
from app.api.permissions import bp as permissions_bp
from app.api.setup import bp as setup_bp
from app.api.users import bp as users_bp
bp = Blueprint("v1", __name__, url_prefix="/api/v1")
bp.register_blueprint(health_bp)
bp.register_blueprint(diag_bp)
bp.register_blueprint(setup_bp)
bp.register_blueprint(auth_bp)
bp.register_blueprint(invitations_bp)
bp.register_blueprint(users_bp)
bp.register_blueprint(groups_bp)
bp.register_blueprint(permissions_bp)

View File

View File

@@ -0,0 +1,76 @@
"""Runtime configuration loaded from environment variables."""
from __future__ import annotations
from typing import Literal
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Sentinel values that .env.example ships with. If the runtime is configured
# in a non-dev environment with one of these still in place, we refuse to boot.
_DEV_JWT_SECRET = "change-me-to-a-long-random-string"
_DEV_DB_PASSWORD = "change-me-strong"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
)
# === Runtime mode ===
# Set to "dev" to allow the default placeholder secrets. Anything else
# (e.g. "prod", "staging") forces strong values.
APP_ENV: Literal["dev", "prod", "staging", "test"] = "prod"
# === Postgres ===
POSTGRES_DB: str = "metamorph"
POSTGRES_USER: str = "metamorph"
POSTGRES_PASSWORD: str = ""
POSTGRES_HOST: str = "db"
POSTGRES_PORT: int = 5432
# === API ===
JWT_SECRET: str = Field(default="", min_length=0)
LOG_LEVEL: str = "INFO"
FRONT_ORIGIN: str = "http://localhost:8080"
EVIDENCE_DIR: str = "/data/evidence"
@property
def cors_origins(self) -> list[str]:
return [o.strip() for o in self.FRONT_ORIGIN.split(",") if o.strip()]
@property
def database_url(self) -> str:
"""SQLAlchemy URL using the psycopg3 driver."""
return (
f"postgresql+psycopg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@model_validator(mode="after")
def _enforce_secret_strength(self) -> "Settings":
"""Refuse to boot in prod/staging if secrets are missing or default.
`dev` and `test` are explicitly exempted so workstations and the
ephemeral test container don't need real secrets.
"""
if self.APP_ENV in ("dev", "test"):
return self
if not self.JWT_SECRET or self.JWT_SECRET == _DEV_JWT_SECRET or len(self.JWT_SECRET) < 32:
raise ValueError(
"JWT_SECRET is missing, default, or shorter than 32 chars. "
"Set APP_ENV=dev to bypass for local development."
)
if not self.POSTGRES_PASSWORD or self.POSTGRES_PASSWORD == _DEV_DB_PASSWORD:
raise ValueError(
"POSTGRES_PASSWORD is missing or default. "
"Set APP_ENV=dev to bypass for local development."
)
return self
settings = Settings()

View File

@@ -0,0 +1,34 @@
"""JSON structured logging on stdout."""
from __future__ import annotations
import logging
import sys
from pythonjsonlogger import jsonlogger
def configure_logging(level: str = "INFO") -> None:
"""Replace the root handler with a single JSON stdout handler.
Fields emitted: ts, level, name, msg, plus any extras passed via `logger.X(..., extra={...})`.
"""
root = logging.getLogger()
root.setLevel(level.upper())
# Drop any pre-existing handlers (uvicorn/gunicorn add their own).
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler(sys.stdout)
formatter = jsonlogger.JsonFormatter(
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
rename_fields={"asctime": "ts", "levelname": "level", "name": "logger"},
json_ensure_ascii=False,
)
handler.setFormatter(formatter)
root.addHandler(handler)
# Tame the noisy third parties unless explicitly debugging.
if level.upper() != "DEBUG":
logging.getLogger("werkzeug").setLevel(logging.WARNING)

View File

72
backend/app/main.py Normal file
View File

@@ -0,0 +1,72 @@
"""Flask application factory and WSGI entry point."""
from __future__ import annotations
import logging
from flask import Flask
from flask_cors import CORS
from app.api.v1 import bp as v1_bp
from app.core.config import settings
from app.core.install_token import (
ensure_install_token,
log_install_token_banner,
)
from app.core.logging import configure_logging
from app.core.rate_limit import limiter
from app.services.bootstrap import ensure_system_groups
from app.services.permissions_seed import seed_all as seed_permissions_and_bindings
def _try_bootstrap_at_boot(log: logging.Logger) -> None:
"""Best-effort: seed system groups + mint an install token if needed.
Wrapped in try/except because the DB may not be ready (or schema not
migrated yet) at the very first boot — gunicorn must still come up so the
operator can run `make migrate` and curl /setup afterwards.
"""
try:
ensure_system_groups()
seed_permissions_and_bindings()
token = ensure_install_token()
if token is not None:
log_install_token_banner(token)
else:
log.info("metamorph.bootstrap.skipped")
except Exception as e:
log.warning("metamorph.bootstrap.deferred", extra={"error": str(e)})
def create_app() -> Flask:
configure_logging(settings.LOG_LEVEL)
log = logging.getLogger("metamorph.boot")
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 MB hard cap; per-file limit is 25 MB.
CORS(
app,
origins=settings.cors_origins,
supports_credentials=True,
max_age=600,
)
limiter.init_app(app)
app.register_blueprint(v1_bp)
log.info(
"metamorph.api.boot",
extra={
"cors_origins": settings.cors_origins,
"log_level": settings.LOG_LEVEL,
"evidence_dir": settings.EVIDENCE_DIR,
},
)
_try_bootstrap_at_boot(log)
return app
# WSGI entry point used by gunicorn (`gunicorn app.main:app`).
app = create_app()

60
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,60 @@
[project]
name = "metamorph-api"
version = "0.1.0"
description = "Metamorph backend API — collaborative purple-team platform."
requires-python = ">=3.12"
license = { text = "Proprietary" }
dependencies = [
"flask>=3.0,<4.0",
"flask-cors>=4.0,<5.0",
"flask-limiter>=3.7,<4.0",
"pydantic[email]>=2.6,<3.0",
"pydantic-settings>=2.2,<3.0",
"python-json-logger>=2.0,<3.0",
"gunicorn>=21.2,<22.0",
"sqlalchemy>=2.0,<3.0",
"alembic>=1.13,<2.0",
"psycopg[binary]>=3.1,<4.0",
"argon2-cffi>=23.1,<25.0",
"pyjwt>=2.8,<3.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0,<9.0",
"pytest-cov>=5.0,<6.0",
"ruff>=0.4,<1.0",
"httpx>=0.27,<1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"SIM", # flake8-simplify
"RUF", # ruff-specific
]
ignore = ["E501"] # line length handled by formatter
[tool.ruff.format]
quote-style = "double"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"

82
docker-compose.yml Normal file
View File

@@ -0,0 +1,82 @@
services:
db:
image: docker.io/library/postgres:16-alpine
container_name: metamorph-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- metamorph_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- metamorph
# No ports exposed on the host: the api reaches it on the internal network.
api:
build:
context: ./backend
dockerfile: Dockerfile
target: runtime
container_name: metamorph-api
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_PORT: ${POSTGRES_PORT}
JWT_SECRET: ${JWT_SECRET}
LOG_LEVEL: ${LOG_LEVEL}
FRONT_ORIGIN: ${FRONT_ORIGIN}
EVIDENCE_DIR: ${EVIDENCE_DIR}
volumes:
- metamorph_evidence:/data/evidence
depends_on:
db:
condition: service_healthy
ports:
- "${HOST_API_PORT:-8000}:8000"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- metamorph
front:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
container_name: metamorph-front
restart: unless-stopped
depends_on:
- api
ports:
- "${HOST_FRONT_PORT:-8080}:80"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
networks:
- metamorph
volumes:
metamorph_db:
metamorph_evidence:
networks:
metamorph:
driver: bridge

12
e2e/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,12 @@
/* eslint-env node */
module.exports = {
root: true,
env: { node: true, es2022: true },
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
ignorePatterns: ['playwright-report', 'test-results', 'node_modules', '.eslintrc.cjs'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};

5
e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
playwright-report
test-results
playwright/.cache
*.log

66
e2e/README.md Normal file
View File

@@ -0,0 +1,66 @@
# Metamorph e2e
End-to-end tests powered by [Playwright](https://playwright.dev/). Each milestone in `tasks/todo.md` should add at least one spec file (`tests/m<N>-*.spec.ts`).
## One-time setup
```bash
cd e2e
npm install
npm run install-browsers # downloads chromium (uses sudo for system deps)
```
## Running against a live stack
```bash
# 1. Bring the stack up from the repo root:
cd .. && make up
# 2. Run the tests:
cd e2e && npm test
# 3. Open the HTML report:
npm run report # opens playwright-report/index.html in your browser
```
Or from the repo root:
```bash
make e2e # runs against the already-up stack
make e2e-report # opens the HTML report
make e2e-up # one-shot: make up + wait healthy + run tests
```
## Auto-spawn mode
Set `PW_AUTOSTART=1` to let Playwright spawn `make up` itself before the run:
```bash
PW_AUTOSTART=1 npm test
```
## Configuration
| Env var | Default | Purpose |
|--------------|--------------------------|-----------------------------------------------|
| `BASE_URL` | `http://localhost:8080` | The front nginx URL (which proxies `/api/*`) |
| `PW_AUTOSTART` | `0` | If `1`, spawn `make up` before the tests |
| `CI` | unset | When set, retries=2 and parallel workers=2 |
## Reports
- **HTML** : `e2e/playwright-report/index.html`
- **JUnit** : `e2e/playwright-report/junit.xml` (CI ingestion)
- **Trace** : kept on first retry, opened with `npx playwright show-trace …`
## Layout
```
e2e/
├── tests/
│ └── m0-smoke.spec.ts # bootstrap milestone (current)
│ └── m<N>-*.spec.ts # one spec per milestone, added as features land
├── playwright.config.ts
├── tsconfig.json
└── package.json
```

1841
e2e/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
e2e/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "metamorph-e2e",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "PWDEBUG=1 playwright test",
"report": "playwright show-report",
"install-browsers": "playwright install --with-deps chromium",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"eslint": "^8.57.0",
"typescript": "^5.4.5"
},
"engines": {
"node": ">=20"
}
}

60
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,60 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for Metamorph end-to-end tests.
*
* Run modes:
* 1. Against an already-running stack (default in CI/local):
* cd e2e && BASE_URL=http://localhost:8080 npm test
* 2. With auto-spawned dev servers — set PW_AUTOSTART=1 (see `webServer` block).
*
* Reports:
* - HTML report → `e2e/playwright-report/` (open with `npm run report`)
* - JUnit XML → `e2e/playwright-report/junit.xml` (CI ingestion)
* - Traces and screenshots are kept on retry for forensics.
*/
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080';
const AUTOSTART = process.env.PW_AUTOSTART === '1';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
// The stack uses a shared Postgres. Each spec that calls /diag/reset wipes
// global state, so we must serialise execution to avoid spec-vs-spec races
// (notably the install-token reset and the per-spec admin bootstrap).
fullyParallel: false,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }],
['junit', { outputFile: 'playwright-report/junit.xml' }],
],
use: {
baseURL: BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// Optional: spawn the compose stack via `make up` before the tests run.
// Disabled by default — rely on the operator to bring the stack up.
...(AUTOSTART
? {
webServer: {
command: 'cd .. && make up',
url: `${BASE_URL}/api/v1/health`,
reuseExistingServer: true,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
}
: {}),
});

127
e2e/tests/m0-smoke.spec.ts Normal file
View File

@@ -0,0 +1,127 @@
import { test, expect, type Request } from '@playwright/test';
/**
* M0 — Bootstrap smoke checks.
* Validates what M0 actually delivers:
* 1. The 3-container stack is reachable (front + api proxy).
* 2. The home page renders the RTOps design system primitives.
* 3. Self-hosted webfonts (no Google Fonts CDN — spec §7).
* 4. No JS console errors on first load.
* 5. API health endpoint returns the expected JSON.
*/
test.describe('M0 — bootstrap smoke', () => {
const consoleErrors: string[] = [];
const externalRequests: string[] = [];
test.beforeEach(({ page }) => {
consoleErrors.length = 0;
externalRequests.length = 0;
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
page.on('request', (req: Request) => {
const url = req.url();
if (
url.includes('fonts.googleapis.com') ||
url.includes('fonts.gstatic.com') ||
url.includes('cdn.jsdelivr.net') ||
url.includes('unpkg.com')
) {
externalRequests.push(url);
}
});
});
test('home page loads and renders the RTOps header', async ({ page }) => {
const resp = await page.goto('/');
expect(resp?.status(), 'home page should respond 200').toBe(200);
await expect(page).toHaveTitle(/Metamorph/);
const h1 = page.getByRole('heading', { level: 1 });
await expect(h1).toContainText('Metamorph');
await expect(h1).toContainText('Purple Team Platform');
});
test('API health card eventually shows OK', async ({ page }) => {
await page.goto('/');
// The "API" card binds the health probe; wait for the green-accent state.
const apiCard = page.locator('h3', { hasText: /^API$/ }).locator('..');
await expect(apiCard).toContainText(/version\s+\d+/i, { timeout: 10_000 });
await expect(apiCard).toContainText('ok');
});
test('design system primitives render with the expected accent classes', async ({ page }) => {
await page.goto('/');
// Tags from the demo row.
await expect(page.getByText('EVASION', { exact: true })).toBeVisible();
await expect(page.getByText('C2', { exact: true })).toBeVisible();
await expect(page.getByText('LATERAL', { exact: true })).toBeVisible();
// Flow nodes.
await expect(page.getByText('recon', { exact: true })).toBeVisible();
await expect(page.getByText('impact', { exact: true })).toBeVisible();
// Buttons.
await expect(page.getByRole('button', { name: /primary/i })).toBeVisible();
});
test('body uses self-hosted IBM Plex Sans, no Google Fonts requests', async ({ page }) => {
await page.goto('/');
// Wait for fonts to settle.
await page.evaluate(() => document.fonts.ready);
const bodyFont = await page.evaluate(() =>
window.getComputedStyle(document.body).fontFamily.toLowerCase(),
);
expect(bodyFont).toContain('ibm plex sans');
// Header is mono.
const h1Font = await page.evaluate(() => {
const h1 = document.querySelector('h1');
return h1 ? window.getComputedStyle(h1).fontFamily.toLowerCase() : '';
});
expect(h1Font).toContain('jetbrains mono');
// No request must hit Google Fonts or any other CDN — see spec §7.
expect(externalRequests, `unexpected CDN traffic: ${externalRequests.join(', ')}`).toEqual([]);
});
test('background uses the RTOps deep navy token', async ({ page }) => {
await page.goto('/');
const bg = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor);
// tasks/design.md: --bg = #0a0e1a → rgb(10, 14, 26)
expect(bg).toBe('rgb(10, 14, 26)');
});
test('no JS console errors on first load', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// The auth provider attempts a silent /auth/refresh at mount; without a
// refresh cookie the server returns 401 and the browser logs a generic
// "Failed to load resource" warning. That's expected for unauthenticated
// visitors and doesn't constitute a real error.
const realErrors = consoleErrors.filter(
(e) => !/Failed to load resource.*401/i.test(e),
);
expect(realErrors, `console errors: ${realErrors.join(' | ')}`).toEqual([]);
});
test('API health endpoint returns the expected JSON shape', async ({ request }) => {
const resp = await request.get('/api/v1/health');
expect(resp.status()).toBe(200);
const body = (await resp.json()) as { status: string; version: string };
expect(body.status).toBe('ok');
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
});
test('CORS headers are set when the SPA origin asks for them', async ({ request }) => {
const resp = await request.get('/api/v1/health', {
headers: { Origin: 'http://localhost:8080' },
});
expect(resp.status()).toBe(200);
// flask-cors echoes back the configured origin when allowed.
const allowed = resp.headers()['access-control-allow-origin'];
expect(allowed === 'http://localhost:8080' || allowed === '*').toBeTruthy();
});
});

18
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"noEmit": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "tests/**/*"]
}

7
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.vite
*.log
.env
.env.*
!.env.example

18
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
/* eslint-env node */
module.exports = {
root: true,
env: { browser: true, es2022: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'node_modules'],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};

8
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
# === Stage 1: build the SPA bundle ===
FROM docker.io/library/node:20-alpine AS builder
ARG VITE_API_BASE_URL=/api/v1
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
WORKDIR /app
COPY package.json ./
# When a lockfile is committed (npm/pnpm/yarn), prefer `npm ci` for reproducibility.
RUN if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi
COPY tsconfig*.json vite.config.ts tailwind.config.ts postcss.config.js index.html ./
COPY src ./src
RUN npm run build
# === Stage 2: serve via nginx ===
FROM docker.io/library/nginx:1.27-alpine AS runtime
# Drop the default config and use ours.
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/metamorph.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz || exit 1

39
frontend/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Metamorph frontend
Vite + React 18 + TypeScript + TailwindCSS. Design tokens from `../tasks/design.md` are in `tailwind.config.ts`.
## Local dev
```bash
npm install
npm run dev # http://localhost:5173 (proxies /api/* to http://localhost:8000)
```
## Build
```bash
npm run build # outputs to dist/
npm run preview # serves dist/ on http://localhost:8080
```
## Quality
```bash
npm run typecheck
npm run lint
npm run format
```
## Layout
```
src/
├── App.tsx # M0 home page (health check + design tokens demo)
├── main.tsx
├── index.css # Tailwind base + tinted-accent utilities
├── components/ui/ # RTOps design primitives: Card, Tag, SectionHeader, FlowNode, Button
├── lib/
│ ├── api.ts # fetch wrapper (M2 will replace with auth-aware client)
│ └── cn.ts # classnames + ACCENTS palette
└── vite-env.d.ts
```

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Metamorph</title>
</head>
<body class="bg-bg text-text font-sans">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

45
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,45 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Reasonable hardening — TLS is terminated by an external reverse proxy
# so we don't add HSTS here (let the edge own that header).
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "same-origin" always;
# Internal liveness for the docker healthcheck.
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# Proxy API calls to the Flask service on the compose network.
location /api/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
client_max_body_size 64m;
}
# SPA fallback — every unknown route returns index.html.
location / {
try_files $uri $uri/ /index.html;
}
# Long cache for hashed assets.
location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|webp|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "metamorph-front",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --port 8080",
"lint": "eslint .",
"typecheck": "tsc -b --pretty",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json,html}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\""
},
"dependencies": {
"@fontsource/ibm-plex-sans": "^5.0.20",
"@fontsource/jetbrains-mono": "^5.0.20",
"@tanstack/react-query": "^5.51.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@types/node": "^20.12.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.38",
"prettier": "^3.3.0",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

85
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,85 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { RequireAdmin } from '@/components/RequireAdmin';
import { RequireAuth } from '@/components/RequireAuth';
import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
import { AdminUsersPage } from '@/pages/AdminUsersPage';
import { HomePage } from '@/pages/HomePage';
import { LoginPage } from '@/pages/LoginPage';
import { ProfilePage } from '@/pages/ProfilePage';
import { RegisterPage } from '@/pages/RegisterPage';
import { SetupPage } from '@/pages/SetupPage';
import { AuthContext, useProvideAuth } from '@/lib/auth';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useProvideAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route element={<Layout />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/setup" element={<SetupPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Home page stays public — it's an ops dashboard, not sensitive. */}
<Route path="/" element={<HomePage />} />
<Route
path="/profile"
element={
<RequireAuth>
<ProfilePage />
</RequireAuth>
}
/>
<Route
path="/admin/users"
element={
<RequireAdmin>
<AdminUsersPage />
</RequireAdmin>
}
/>
<Route
path="/admin/groups"
element={
<RequireAdmin>
<AdminGroupsPage />
</RequireAdmin>
}
/>
<Route
path="/admin/invitations"
element={
<RequireAdmin>
<AdminInvitationsPage />
</RequireAdmin>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;

View File

@@ -0,0 +1,45 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn, type Accent } from '@/lib/cn';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
accent?: Accent;
variant?: 'solid' | 'outline' | 'ghost';
children: ReactNode;
}
const ACCENT_OUTLINE: Record<Accent, string> = {
red: 'border-red text-red hover:bg-red/10',
orange: 'border-orange text-orange hover:bg-orange/10',
yellow: 'border-yellow text-yellow hover:bg-yellow/10',
green: 'border-green text-green hover:bg-green/10',
cyan: 'border-cyan text-cyan hover:bg-cyan/10',
blue: 'border-blue text-blue hover:bg-blue/10',
purple: 'border-purple text-purple hover:bg-purple/10',
pink: 'border-pink text-pink hover:bg-pink/10',
rose: 'border-rose text-rose hover:bg-rose/10',
teal: 'border-teal text-teal hover:bg-teal/10',
};
/** Minimal button matching the briefing aesthetic — no shadows, thin borders. */
export function Button({
accent = 'cyan',
variant = 'outline',
className,
children,
...rest
}: ButtonProps) {
const base =
'inline-flex items-center justify-center rounded-md border px-3 py-2 font-mono text-xs font-medium uppercase tracking-wider2 disabled:opacity-50 disabled:pointer-events-none';
const variantCls =
variant === 'outline'
? ACCENT_OUTLINE[accent]
: variant === 'ghost'
? 'border-transparent text-text hover:bg-bg-card'
: 'border-transparent bg-bg-card text-text-bright';
return (
<button className={cn(base, variantCls, className)} {...rest}>
{children}
</button>
);
}

View File

@@ -0,0 +1,50 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn, type Accent } from '@/lib/cn';
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
/** Accent border color — distinguishes the card's category. */
accent?: Accent;
/** Card heading. Renamed from the native HTMLAttributes.title (string-only). */
title?: ReactNode;
/** Subtitle / metadata line below the title. */
sub?: ReactNode;
children?: ReactNode;
}
const ACCENT_BORDER: Record<Accent, string> = {
red: 'border-red',
orange: 'border-orange',
yellow: 'border-yellow',
green: 'border-green',
cyan: 'border-cyan',
blue: 'border-blue',
purple: 'border-purple',
pink: 'border-pink',
rose: 'border-rose',
teal: 'border-teal',
};
/** Card from design.md §5.3 — shared chrome, accent-only differentiation. */
export function Card({ accent, title, sub, children, className, ...rest }: CardProps) {
return (
<div
className={cn(
'bg-bg-card rounded-lg border p-5',
accent ? ACCENT_BORDER[accent] : 'border-border',
className,
)}
{...rest}
>
{title && (
<h3 className="font-mono text-sm font-semibold text-text-bright mb-1">{title}</h3>
)}
{sub && (
<div className="font-mono text-4xs uppercase tracking-wider2 text-text-dim mb-3">
{sub}
</div>
)}
{children && <div className="text-xs leading-[1.7]">{children}</div>}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
import { cn, type Accent } from '@/lib/cn';
interface FlowNodeProps {
accent?: Accent;
children: ReactNode;
className?: string;
}
const ACCENT_BORDER_TEXT: Record<Accent, string> = {
red: 'border-red text-red',
orange: 'border-orange text-orange',
yellow: 'border-yellow text-yellow',
green: 'border-green text-green',
cyan: 'border-cyan text-cyan',
blue: 'border-blue text-blue',
purple: 'border-purple text-purple',
pink: 'border-pink text-pink',
rose: 'border-rose text-rose',
teal: 'border-teal text-teal',
};
/** Flow node from design.md §5.5 — chained horizontally with arrows in flex rows. */
export function FlowNode({ accent, children, className }: FlowNodeProps) {
return (
<span
className={cn(
'inline-block bg-bg-card rounded-md border px-3 py-2 font-mono text-4xs whitespace-nowrap shrink-0',
accent ? ACCENT_BORDER_TEXT[accent] : 'border-border text-text',
className,
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import { cn, type Accent } from '@/lib/cn';
interface SectionHeaderProps {
/** Plain text leading the colored word. */
prefix?: string;
/** The single colored word in the title. */
highlight: string;
accent?: Accent;
description?: ReactNode;
className?: string;
}
const ACCENT_TEXT: Record<Accent, string> = {
red: 'text-red',
orange: 'text-orange',
yellow: 'text-yellow',
green: 'text-green',
cyan: 'text-cyan',
blue: 'text-blue',
purple: 'text-purple',
pink: 'text-pink',
rose: 'text-rose',
teal: 'text-teal',
};
/**
* Section header from design.md §5.2 — every h2 starts with a cyan `//`,
* a plain word, and exactly one colored word.
*/
export function SectionHeader({
prefix,
highlight,
accent = 'red',
description,
className,
}: SectionHeaderProps) {
return (
<div className={cn('mt-[60px] mb-[30px]', className)}>
<h2 className="font-mono text-lg font-semibold text-text-bright pb-3 border-b border-border">
<span className="text-cyan">{'// '}</span>
{prefix && <span>{prefix} </span>}
<span className={ACCENT_TEXT[accent]}>{highlight}</span>
</h2>
{description && (
<p className="font-mono text-xs text-text-dim mt-2">{description}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
import { cn, type Accent } from '@/lib/cn';
interface TagProps {
accent: Accent;
children: ReactNode;
className?: string;
}
const ACCENT_FILL: Record<Accent, string> = {
red: 'accent-fill-red',
orange: 'accent-fill-orange',
yellow: 'accent-fill-yellow',
green: 'accent-fill-green',
cyan: 'accent-fill-cyan',
blue: 'accent-fill-blue',
purple: 'accent-fill-purple',
pink: 'accent-fill-pink',
rose: 'accent-fill-rose',
teal: 'accent-fill-teal',
};
/** Tag/pill from design.md §5.4 — 9px uppercase mono, tinted fill. */
export function Tag({ accent, children, className }: TagProps) {
return (
<span
className={cn(
'inline-block rounded font-mono text-3xs font-semibold uppercase tracking-wider2 px-2 py-[3px] mr-1 mb-2',
ACCENT_FILL[accent],
className,
)}
>
{children}
</span>
);
}

49
frontend/src/index.css Normal file
View File

@@ -0,0 +1,49 @@
/* Self-hosted webfonts — no runtime CDN (cf. spec §7). */
@import '@fontsource/jetbrains-mono/300.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/jetbrains-mono/600.css';
@import '@fontsource/jetbrains-mono/700.css';
@import '@fontsource/ibm-plex-sans/300.css';
@import '@fontsource/ibm-plex-sans/400.css';
@import '@fontsource/ibm-plex-sans/500.css';
@import '@fontsource/ibm-plex-sans/600.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
@apply min-h-screen;
}
body {
@apply bg-bg text-text font-sans;
line-height: 1.6;
}
/* No transitions / hovers / animations baseline (cf. design.md §7). */
*:focus-visible {
outline: 1px solid theme('colors.cyan');
outline-offset: 2px;
}
::selection {
background: rgb(6 182 212 / 0.25);
color: theme('colors.text-bright');
}
}
@layer utilities {
/* Tinted accent fill — see design.md §2 "tinted fills". */
.accent-fill-red { background: rgb(239 68 68 / 0.15); color: theme('colors.red'); }
.accent-fill-orange { background: rgb(245 158 11 / 0.15); color: theme('colors.orange'); }
.accent-fill-yellow { background: rgb(234 179 8 / 0.15); color: theme('colors.yellow'); }
.accent-fill-green { background: rgb(16 185 129 / 0.15); color: theme('colors.green'); }
.accent-fill-cyan { background: rgb(6 182 212 / 0.15); color: theme('colors.cyan'); }
.accent-fill-blue { background: rgb(59 130 246 / 0.15); color: theme('colors.blue'); }
.accent-fill-purple { background: rgb(139 92 246 / 0.15); color: theme('colors.purple'); }
.accent-fill-pink { background: rgb(236 72 153 / 0.15); color: theme('colors.pink'); }
.accent-fill-rose { background: rgb(244 63 94 / 0.15); color: theme('colors.rose'); }
.accent-fill-teal { background: rgb(20 184 166 / 0.15); color: theme('colors.teal'); }
}

19
frontend/src/lib/cn.ts Normal file
View File

@@ -0,0 +1,19 @@
/** Tiny classnames helper — keeps deps minimal. */
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
export const ACCENTS = [
'red',
'orange',
'yellow',
'green',
'cyan',
'blue',
'purple',
'pink',
'rose',
'teal',
] as const;
export type Accent = (typeof ACCENTS)[number];

14
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from '@/App';
import '@/index.css';
const root = document.getElementById('root');
if (!root) throw new Error('#root not found in index.html');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { FlowNode } from '@/components/ui/FlowNode';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { apiGet } from '@/lib/api';
interface HealthResponse {
status: string;
version: string;
}
interface DbDiagResponse {
reachable: boolean;
alembic_revision?: string | null;
table_count?: number;
error?: string;
}
type HealthState =
| { kind: 'loading' }
| { kind: 'ok'; data: HealthResponse }
| { kind: 'error'; error: string };
type DbState =
| { kind: 'loading' }
| { kind: 'ok'; data: DbDiagResponse }
| { kind: 'error'; error: string };
export function HomePage() {
const [health, setHealth] = useState<HealthState>({ kind: 'loading' });
const [db, setDb] = useState<DbState>({ kind: 'loading' });
useEffect(() => {
let cancelled = false;
apiGet<HealthResponse>('/health', { anonymous: true })
.then((data) => !cancelled && setHealth({ kind: 'ok', data }))
.catch((err: unknown) =>
!cancelled &&
setHealth({ kind: 'error', error: err instanceof Error ? err.message : String(err) }),
);
apiGet<DbDiagResponse>('/diag/db', { anonymous: true })
.then((data) => !cancelled && setDb({ kind: 'ok', data }))
.catch((err: unknown) =>
!cancelled &&
setDb({ kind: 'error', error: err instanceof Error ? err.message : String(err) }),
);
return () => {
cancelled = true;
};
}, []);
return (
<>
<header className="text-center mb-12">
<h1 className="font-mono text-[28px] font-bold tracking-tight text-text-bright">
<span className="text-red">Meta</span>
<span>morph</span>{' '}
<span className="text-purple">Purple Team Platform</span>
</h1>
<p className="font-mono text-sm font-light text-text-dim mt-2">
Collaborative red &amp; blue test orchestration M3 milestone (RBAC)
</p>
</header>
<SectionHeader
prefix="System"
highlight="Health"
accent="cyan"
description="Live status pulled from /api/v1/health and /api/v1/diag/db."
/>
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(420px,1fr))]">
<Card
accent={health.kind === 'ok' ? 'green' : health.kind === 'error' ? 'red' : 'cyan'}
title="API"
sub={
health.kind === 'loading'
? 'probing…'
: health.kind === 'error'
? 'unreachable'
: `version ${health.data.version}`
}
>
{health.kind === 'loading' && <p>Waiting for the backend</p>}
{health.kind === 'ok' && (
<p>
Status:{' '}
<code className="accent-fill-green px-2 py-[2px] rounded-sm font-mono text-4xs">
{health.data.status}
</code>
</p>
)}
{health.kind === 'error' && (
<p>
Error:{' '}
<code className="accent-fill-red px-2 py-[2px] rounded-sm font-mono text-4xs">
{health.error}
</code>
</p>
)}
</Card>
<Card
accent={db.kind === 'ok' && db.data.reachable ? 'blue' : db.kind === 'error' ? 'red' : 'cyan'}
title="Database"
sub={
db.kind === 'loading'
? 'probing…'
: db.kind === 'error'
? 'unreachable'
: db.data.reachable
? `revision ${db.data.alembic_revision?.slice(0, 8) ?? 'unknown'}`
: 'unreachable'
}
>
{db.kind === 'ok' && db.data.reachable && (
<p>
Tables:{' '}
<code
className="accent-fill-blue px-2 py-[2px] rounded-sm font-mono text-4xs"
data-testid="db-table-count"
>
{db.data.table_count}
</code>{' '}
· Alembic head reached.
</p>
)}
{(db.kind === 'loading' || (db.kind === 'ok' && !db.data.reachable)) && (
<p>Querying schema metadata</p>
)}
{db.kind === 'error' && (
<p>
Error:{' '}
<code className="accent-fill-red px-2 py-[2px] rounded-sm font-mono text-4xs">
{db.error}
</code>
</p>
)}
</Card>
<Card accent="purple" title="Roadmap" sub="14 milestones">
<p>
M0 + M1 + M2 + M3 done. Next:{' '}
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
M4 MITRE ATT&amp;CK
</code>
.
</p>
</Card>
</div>
<SectionHeader
prefix="Design"
highlight="Tokens"
accent="orange"
description="Sanity check of the RTOps design system primitives (cf. tasks/design.md)."
/>
<div className="mb-6">
<Tag accent="red">EVASION</Tag>
<Tag accent="purple">C2</Tag>
<Tag accent="cyan">LATERAL</Tag>
<Tag accent="orange">CRED</Tag>
<Tag accent="green">PHISH</Tag>
<Tag accent="teal">PERSIST</Tag>
</div>
<div className="flex items-center gap-1 flex-wrap py-3 mb-6">
<FlowNode accent="orange">recon</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="green">phish</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="purple">c2</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="cyan">lateral</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="red">impact</FlowNode>
</div>
<div className="flex gap-2">
<Button accent="cyan">Primary</Button>
<Button accent="red">Danger</Button>
<Button variant="ghost">Cancel</Button>
</div>
</>
);
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,66 @@
import type { Config } from 'tailwindcss';
/**
* Design tokens from `tasks/design.md` — Red Team Operations Map.
* Dark, flat, terminal-inspired. Color-as-taxonomy: each accent maps to a category.
*/
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Surfaces
bg: '#0a0e1a',
'bg-card': '#111827',
border: '#1e2d3d',
// Text
text: '#94a3b8',
'text-bright': '#f8fafc',
'text-dim': '#64748b',
'text-comment': '#475569',
// Accent palette (each one means a category — see tasks/design.md §2)
red: '#ef4444',
orange: '#f59e0b',
yellow: '#eab308',
green: '#10b981',
cyan: '#06b6d4',
blue: '#3b82f6',
purple: '#8b5cf6',
pink: '#ec4899',
rose: '#f43f5e',
teal: '#14b8a6',
},
fontFamily: {
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
sans: ['"IBM Plex Sans"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
fontSize: {
// Custom scale matching design.md §3 — extends the default Tailwind ramp.
// 2xs = arrow labels (8px), 3xs = tag/pill (9px), 4xs = card sub-label, flow node, inline code (10px).
// 11px (pre/footer), 12px (body/section desc), 13/14px (card title), 18px (section h2),
// and 28px (page h1) all already exist in the default ramp.
'2xs': ['8px', '1.4'],
'3xs': ['9px', '1.4'],
'4xs': ['10px', '1.4'],
},
borderRadius: {
sm: '3px',
DEFAULT: '4px',
md: '6px',
lg: '10px',
},
maxWidth: {
page: '1400px',
},
letterSpacing: {
wider2: '1px',
},
},
},
// Safelist accent classes used dynamically (tag categories, flow nodes).
safelist: [
{ pattern: /^(border|text|bg)-(red|orange|yellow|green|cyan|blue|purple|pink|rose|teal)$/ },
{ pattern: /^(border|text|bg)-(red|orange|yellow|green|cyan|blue|purple|pink|rose|teal)\/(\d+)$/ },
],
plugins: [],
} satisfies Config;

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

29
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
// In `npm run dev`, proxy /api/* to the local Flask backend.
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
target: 'es2022',
},
});

376
tasks/design.md Normal file
View File

@@ -0,0 +1,376 @@
# Design System — Red Team Operations Map
Reusable design spec extracted from `kypvas.github.io/red-team-map/`. Dark "operator briefing / terminal" aesthetic: information-dense, color-coded taxonomy, monospace-first, zero ornament.
---
## 1. Philosophy
- **Dark, flat, terminal-inspired.** No gradients, no drop shadows, no glows. Depth comes from 1px borders on slightly lighter card backgrounds.
- **Information over decoration.** Every visual element serves data density — cards, tags, colored borders, inline code.
- **Color as taxonomy.** 10 accent hues are not decoration — each one *means* a category (red = evasion/payload, cyan = lateral, purple = C2, etc.). Reuse hues consistently across projects so color carries meaning.
- **Monospace as identity.** `JetBrains Mono` for everything structural (titles, labels, code, tags). `IBM Plex Sans` only for prose body.
- **Comment-style section markers.** Headings begin with `//` — carries the "source code / operator notes" metaphor.
---
## 2. Color Tokens
All colors are declared as CSS custom properties on `:root`. Copy-paste verbatim:
```css
:root {
/* Surfaces */
--bg: #0a0e1a; /* page background — deep navy-black */
--bg-card: #111827; /* card / panel background */
--border: #1e2d3d; /* default 1px border, separators */
/* Text */
--text: #94a3b8; /* default body copy (slate) */
--text-bright: #f8fafc; /* titles, emphasis */
--text-dim: #64748b; /* metadata, subtitles, arrow labels */
/* #475569 used inline for code comments */
/* Accent palette — each one maps to a category */
--red: #ef4444; /* evasion, payload, privesc, danger */
--orange: #f59e0b; /* access, credentials, AD, MOTW */
--yellow: #eab308; /* exfil */
--green: #10b981; /* phishing, social */
--cyan: #06b6d4; /* lateral movement, default code highlight */
--blue: #3b82f6; /* infrastructure, cloud */
--purple: #8b5cf6; /* C2, macOS, tooling */
--pink: #ec4899; /* injection */
--rose: #f43f5e; /* OPSEC, vishing */
--teal: #14b8a6; /* persistence, linux */
}
```
### Usage pattern — tinted fills
Never use accent color as a solid background. Always as `rgba(accent, 0.100.15)` behind solid-colored text:
```css
.tag.c2 { background: rgba(139, 92, 246, 0.15); color: var(--purple); }
.tag.cred { background: rgba(245, 158, 11, 0.15); color: var(--orange); }
code { background: rgba(6, 182, 212, 0.08); color: var(--cyan); }
code.vuln { background: rgba(239, 68, 68, 0.10); color: var(--red); }
code.tool { background: rgba(139, 92, 246, 0.10); color: var(--purple); }
```
---
## 3. Typography
### Font stack
```html
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
```
- **`JetBrains Mono`** — headings, labels, code, tags, navigation, anything structural.
- **`IBM Plex Sans`** — prose body only (`<p>`, card descriptions).
- Weights used: Mono `300 / 400 / 500 / 600 / 700`, Plex Sans `300 / 400 / 500 / 600`.
### Scale
| Role | Family | Size | Weight | Extras |
|-----------------|-----------------|------|--------|---------------------------------|
| Page title (h1) | JetBrains Mono | 28px | 700 | `letter-spacing: -0.5px` |
| Subtitle | JetBrains Mono | 14px | 300 | color `--text-dim` |
| Section (h2) | JetBrains Mono | 18px | 600 | `border-bottom: 1px var(--border)`, `padding-bottom: 12px` |
| Card title (h3) | JetBrains Mono | 14px | 600 | color `--text-bright` |
| Card sub-label | JetBrains Mono | 10px | 400 | `letter-spacing: 0.5px`, `--text-dim` |
| Section desc | JetBrains Mono | 12px | 400 | `--text-dim` |
| Body copy | IBM Plex Sans | 12px | 400 | `line-height: 1.7` |
| Flow node | JetBrains Mono | 10px | 400 | |
| Arrow label | JetBrains Mono | 8px | 400 | `--text-dim` |
| Tag / pill | JetBrains Mono | 9px | 600 | `text-transform: uppercase; letter-spacing: 1px` |
| Inline code | JetBrains Mono | 10px | 400 | |
| `<pre>` block | JetBrains Mono | 11px | 400 | `line-height: 1.7` |
| Footer | JetBrains Mono | 11px | 400 | `--text-dim` |
### Global body
```css
body {
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Sans', sans-serif;
padding: 40px 60px;
line-height: 1.6;
}
```
---
## 4. Layout & Spacing
- **Container**: `max-width: 1400px; margin: 0 auto;`
- **Page padding**: `40px 60px` (desktop-first, no mobile breakpoints in the source)
- **Grid** for card collections:
```css
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
```
- **Section rhythm**: `margin-top: 60px; margin-bottom: 30px` on section headers.
- **Separators**: thin hairlines only — `border-top: 1px solid var(--border); margin: 40px 0`.
- **Line-height**: 1.6 globally, 1.7 inside cards and `<pre>` blocks for dense technical content.
---
## 5. Components
### 5.1 Header / Hero
```html
<header>
<h1>Red Team <span>Operations</span> <span class="acc2">Architecture</span> Map v1.1</h1>
<div class="subtitle">Comprehensive Operator Reference — From Infrastructure to Impact</div>
</header>
```
```css
header { text-align: center; margin-bottom: 50px; }
header h1 { font: 700 28px 'JetBrains Mono'; color: var(--text-bright); letter-spacing: -0.5px; margin-bottom: 8px; }
header h1 span { color: var(--red); }
header h1 .acc2 { color: var(--purple); }
header .subtitle { font: 300 14px 'JetBrains Mono'; color: var(--text-dim); }
```
> **Pattern**: white title with two coloured accent words (red + purple). Reuse `<span>` to highlight 12 keywords only.
### 5.2 Section heading
```html
<div class="section-header">
<h2><span>//</span> Operation <span class="red">Flow Chains</span></h2>
<p class="section-desc">End-to-end attack chains ...</p>
</div>
```
```css
.section-header { margin-top: 60px; margin-bottom: 30px; }
.section-header h2 { font: 600 18px 'JetBrains Mono'; color: var(--text-bright);
padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.section-header h2 span { color: var(--cyan); } /* the "//" marker */
.section-header h2 .red { color: var(--red); } /* + .green / .orange / .purple / .pink / .teal / .yellow / .blue */
.section-desc { font: 12px 'JetBrains Mono'; color: var(--text-dim); margin-top: 8px; }
```
> **Signature move**: every h2 starts with a cyan `//` followed by a plain word and one colored word — source-code comment vibe.
### 5.3 Detail card
```css
.detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
}
.detail-card h3 { font: 600 14px 'JetBrains Mono'; color: var(--text-bright); margin-bottom: 4px; }
.detail-card .card-sub { font: 10px 'JetBrains Mono'; color: var(--text-dim); margin-bottom: 12px; letter-spacing: 0.5px; }
.detail-card .card-body { font-size: 12px; line-height: 1.7; }
/* accent-border variants */
.border-red { border-color: var(--red) !important; }
.border-cyan { border-color: var(--cyan) !important; }
/* ... one class per accent */
```
> Cards share identical chrome; they are **distinguished solely by border color**. That single accent ties card → category → tag → flow-node without repeating the hue anywhere else.
### 5.4 Tag / pill
```css
.tag {
font: 600 9px 'JetBrains Mono';
text-transform: uppercase;
letter-spacing: 1px;
padding: 3px 8px;
border-radius: 4px;
display: inline-block;
margin-bottom: 8px;
margin-right: 4px;
}
.tag.c2 { background: rgba(139, 92, 246, 0.15); color: var(--purple); }
.tag.evasion { background: rgba(239, 68, 68, 0.15); color: var(--red); }
.tag.lateral { background: rgba(6, 182, 212, 0.15); color: var(--cyan); }
/* ... one class per category */
```
### 5.5 Flow node + arrow
Nodes chain horizontally in a `flex` row with thin SVG arrows between them.
```css
.flow-block { margin-bottom: 18px; }
.flow-title { font: 600 12px 'JetBrains Mono'; margin-bottom: 10px; }
.flow-title.red { color: var(--red); } /* one per accent */
.flow-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 10px 0; }
.flow-node {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font: 10px 'JetBrains Mono';
color: var(--text);
white-space: nowrap;
flex-shrink: 0;
}
.flow-node.hl-red { border-color: var(--red); color: var(--red); }
.flow-node.hl-cyan { border-color: var(--cyan); color: var(--cyan); }
/* ... one per accent */
.flow-arrow { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
.flow-arrow svg { width: 36px; height: 20px; }
.flow-arrow .arrow-label { font: 8px 'JetBrains Mono'; color: var(--text-dim); margin-top: -2px; }
```
Arrow SVG template (inline, stroke colour = destination-node accent):
```html
<svg viewBox="0 0 36 20">
<line x1="0" y1="10" x2="31" y2="10"
stroke="#10b981" stroke-width="1.5"
marker-end="url(#arrowG)"/>
</svg>
```
### 5.6 Data-flow / code card
```css
.data-flow-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
margin-bottom: 20px;
}
.data-flow-card h4 { font: 600 13px 'JetBrains Mono'; color: var(--text-bright); margin-bottom: 12px; }
.data-flow-card pre { font: 11px 'JetBrains Mono'; line-height: 1.7; color: var(--text-dim); overflow-x: auto; }
.data-flow-card pre .key { color: var(--cyan); font-weight: 600; }
.data-flow-card pre .val { color: var(--text-bright); }
.data-flow-card pre .type { color: var(--blue); }
.data-flow-card pre .comment { color: #475569; font-style: italic; }
.data-flow-card pre .danger { color: var(--red); font-weight: 600; }
```
> Pseudo-syntax-highlighting via `<span class="key|val|type|comment|danger">` inside `<pre>` blocks — mimics an IDE theme without a real parser.
### 5.7 Inline code
```css
code { font: 10px 'JetBrains Mono'; padding: 2px 6px; border-radius: 3px;
background: rgba(6, 182, 212, 0.08); color: var(--cyan); }
code.vuln { background: rgba(239, 68, 68, 0.10); color: var(--red); }
code.tool { background: rgba(139, 92, 246, 0.10); color: var(--purple); }
```
### 5.8 List inside card
```css
.card-list { list-style: none; padding: 0; }
.card-list li { padding: 3px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.03); }
```
> Near-invisible divider (`rgba(255,255,255,0.03)`) — rhythm without visual noise.
### 5.9 Footer
```css
footer {
text-align: center;
margin-top: 60px;
padding: 30px 0;
border-top: 1px solid var(--border);
font: 11px 'JetBrains Mono';
color: var(--text-dim);
}
```
---
## 6. Borders, Radii, Elevation
| Element | Radius | Border |
|----------------|--------|------------------------------|
| Detail card | 10px | 1px solid var(--border) or accent |
| Data-flow card | 10px | 1px solid var(--border) |
| Flow node | 6px | 1px solid var(--border) or accent |
| Tag | 4px | none |
| Inline code | 3px | none |
- **No `box-shadow` anywhere.**
- **No gradients.** Surfaces are flat hex fills.
- **Depth cue** = border on a `#111827` panel over a `#0a0e1a` background. That's the whole elevation system.
---
## 7. Motion
The stylesheet defines **no transitions, no hovers, no animations**. Static document. If you add motion in derivative work, keep it restrained: ~120 ms fades on border-color at most. Don't introduce scale, shadow, or glow effects — they'd break the briefing aesthetic.
---
## 8. Iconography
No icon font, no Lucide/Heroicons. All pictograms are **inline SVG arrows** with `stroke-width: 1.5` and `<marker-end>` arrowheads, one per accent color. Tags replace icons: `[C2]`, `[EVASION]`, `[LATERAL]` carry the same recognition load.
If icons are ever added, use a thin (1.5px) monochrome line set (e.g. Lucide `strokeWidth={1.5}`) and color them with accent vars.
---
## 9. Reusable Starter Template
Drop-in `<head>` + body baseline for a new project in this style:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Name</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e1a; --bg-card: #111827; --border: #1e2d3d;
--text: #94a3b8; --text-bright: #f8fafc; --text-dim: #64748b;
--red:#ef4444; --orange:#f59e0b; --yellow:#eab308; --green:#10b981;
--cyan:#06b6d4; --blue:#3b82f6; --purple:#8b5cf6; --pink:#ec4899;
--rose:#f43f5e; --teal:#14b8a6;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: 'IBM Plex Sans', sans-serif; padding: 40px 60px; line-height: 1.6; }
.container { max-width: 1400px; margin: 0 auto; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>Project <span style="color:var(--red)">Name</span> <span style="color:var(--purple)">Subtitle</span></h1>
<div class="subtitle">One-line mission statement</div>
</header>
<!-- sections with <h2>// Section <span class="red">Name</span></h2> ... -->
</div>
</body>
</html>
```
---
## 10. Checklist for "Does this match the style?"
- [ ] Background `#0a0e1a`, cards `#111827`, borders `#1e2d3d`.
- [ ] JetBrains Mono for structure, IBM Plex Sans for prose.
- [ ] Every section `<h2>` starts with a cyan `//`.
- [ ] Exactly one accent hue per category, reused across border + tag + code + flow node.
- [ ] Accent backgrounds are **tinted** (`rgba(accent, 0.100.15)`), never solid.
- [ ] Zero shadows, zero gradients, zero rounded > 10px.
- [ ] Tags are 9px uppercase mono with 1px letter-spacing.
- [ ] Container capped at 1400px, page padded `40px 60px`.
- [ ] No hover animations beyond border-color if any at all.

76
tasks/lessons.md Normal file
View File

@@ -0,0 +1,76 @@
---
type: lessons
project: Metamorph
---
# Metamorph — Lessons learned
> Capture session-level retrospectives here: surprises, traps avoided, decisions revisited. Keep entries short and actionable. Most recent first.
## 2026-05-08 — M0 bootstrap
- Spec finalisée d'abord (`tasks/spec.md`), 8 tours de questions ciblées avant tout code → 0 hypothèse latente avant M0. Pattern à reproduire pour les futurs projets greenfield.
- Choix `uv` pour le backend Python (rapidité de lock, image Docker plus mince qu'avec poetry).
- TLS terminé par reverse proxy externe (cf. spec §6 NF-network) → pas de Caddy/Traefik dans le compose, simplifie le M0.
- Bootstrap du 1er admin via token affiché dans les logs : retenu sur Token-in-logs plutôt que ENV pour éviter de mettre le password en clair dans `.env`.
- **Piège Dockerfile** : la process-substitution bash `<(...)` ne marche pas dans une instruction `RUN` Docker car le shell par défaut est `sh`, pas `bash`. Soit ajouter `SHELL ["/bin/bash", "-c"]`, soit refactor sans process-sub. Ici j'ai préféré refactor (plus portable) : `uv venv` + `uv pip install --python /opt/venv/bin/python .`. Quand un `uv.lock` existera, basculer sur `uv sync --frozen --no-dev`.
- Vérification d'un compose sans Docker installé : `python3 -c "import yaml; yaml.safe_load(open('docker-compose.yml'))"` valide la syntaxe YAML, et un script qui croise les `environment:` du compose avec `.env.example` détecte les variables manquantes côté docs.
- **Lancer le subagent `spec-reviewer` à chaque fin de milestone** (HARD RULE 4 du CLAUDE.md global). J'avais oublié à la fin de M0 ; le user me l'a rappelé. Le reviewer a remonté 6 défauts légitimes en quelques minutes (pre-commit absent, fonts via CDN, secrets par défaut non gardés, `make dev` no-op, `database_url` dead-code, Node engines non pinned). À automatiser dans le workflow de fin de milestone.
- **Spec §7 "pas de CDN runtime"** s'applique aussi aux fonts, pas seulement aux libs JS. Self-host via `@fontsource/<name>` plutôt que Google Fonts `<link>` — bonus OPSEC (pas de fingerprinting via fonts.googleapis.com).
- **Pattern de garde de secrets** : un `model_validator` Pydantic qui refuse de booter en `APP_ENV != "dev"` avec des secrets manquants ou égaux aux placeholders de `.env.example`. Coût quasi nul, élimine la classe entière des "oubli de set en prod".
- **Makefile portable docker/podman** : `ENGINE := $(shell command -v docker … podman …)`, puis sélection du compose driver en fonction (`docker compose` vs `podman compose` vs `podman-compose` legacy). Le piège classique `COMPOSE ?=` ne marche pas si on veut conditionner la valeur par défaut sur `ENGINE` — il faut `ifndef COMPOSE` + `ifeq ($(ENGINE),docker)`. Tous les targets restent compose-driven (`$(COMPOSE) exec`, etc.) ; seuls `volumes` / `inspect-health` / `logs-api` ont besoin de `$(ENGINE)` directement, et même là on évite les filtres par label projet (instables entre podman-compose et docker compose) en se reposant sur `container_name:` du compose file.
## 2026-05-10 — M0 DoD validation (réelle, pas paperware)
- **JE DOIS LANCER LE DoD MOI-MÊME avant de déclarer un milestone done.** L'utilisateur me l'a fait remonter ; le `make up` initial échouait sur 3 problèmes que la revue statique n'a pas vus. Règle : à chaque fin de milestone, exécuter le DoD localement (`make up` + smoke + e2e) en plus du spec-reviewer.
- **Podman + Fedora exige des FQDN d'image** (`docker.io/library/postgres:16-alpine`, pas `postgres:16-alpine`). Le mode `short-name-mode=enforcing` fail sans TTY pour prompter. Docker accepte le même préfixe transparente. → Dorénavant tous les `image:` et `FROM …` des projets cross-engine sont qualifiés.
- **`.dockerignore` qui exclut `*.md` casse `pyproject.toml` qui référence `readme = "README.md"`** : hatchling lit le README au build pour valider les métadonnées. Soit on copie le README explicitement, soit on n'exclut pas les `*.md`, soit on retire la clé `readme`. J'ai retiré la clé pour découpler.
- **`extends HTMLAttributes<HTMLDivElement>` clash sur `title`** : la prop native est `string`, donc redéfinir `title?: ReactNode` produit TS2430. Pattern à retenir : `Omit<HTMLAttributes<…>, 'title'>` quand on overload `title`/`color`/`autoFocus` etc.
- **Podman-compose 1.x ne surfait pas les `HEALTHCHECK` du Dockerfile dans `podman inspect`** : il faut redéclarer le healthcheck dans le `docker-compose.yml` pour que `make inspect-health` voie réellement l'état. Bonus : c'est aussi plus portable.
- **Piège shell : `make up 2>&1 | tail -80` bloque** quand la sortie est petite, parce que `tail` bufferise jusqu'à recevoir SIGPIPE en fin de pipeline ; quand le build est lent, on n'a aucune sortie pendant des minutes. Fix : rediriger vers fichier (`>/tmp/log 2>&1`) puis `tail` séparément, ou utiliser le `Monitor` tool pour streamer.
- **`PODMAN_COMPOSE_WARNING_LOGS=false`** masque le banner "Executing external compose provider …" qui spamme chaque commande. À exporter depuis le Makefile.
## 2026-05-10 — M1 schéma DB & migrations
- **Compose pioche le DERNIER stage du Dockerfile par défaut.** En ajoutant un stage `test` après `runtime`, le container `api` s'est mis à exécuter `python -m pytest` au lieu de `gunicorn`, en boucle (exit 1 → restart → exit 1). Fix : `target: runtime` explicite dans `docker-compose.yml`. Règle : **toujours préciser `target:` quand un Dockerfile a >1 stage final viable.**
- **Snapshot vs référence (spec §11)** : pour qu'un snapshot survive à un re-sync de la référence (ex : MITRE qui retire une technique), il faut **dénormaliser les champs descriptifs** dans la table snapshot (ici `mitre_external_id`, `mitre_name`, `mitre_url`) et **ne pas mettre de FK** vers la table source. Si on garde une FK, la cascade détruit la donnée historique (CASCADE) ou bloque le sync (RESTRICT). La dénormalisation est le bon trade-off pour un état figé en lecture après archivage.
- **`SoftDeleteMixin.__table_args__` est silencieusement écrasé** par la classe enfant qui déclare son propre `__table_args__`. Pattern à éviter pour les mixins qui veulent ajouter des contraintes/index. Soit ne rien mettre dans `__table_args__` du mixin (et imposer aux classes de déclarer l'index), soit utiliser `event.listens_for("after_parent_attach", ...)`. J'ai choisi la 1re option : explicite > magique.
- **Workflow Alembic en container** : `alembic revision --autogenerate` crée le fichier dans le container, qu'il faut `podman cp` vers l'host avant rebuild. Sinon perdu. Ajouter ce détail dans la doc M1 (et envisager un bind mount `dev` plus tard).
- **Bypass `APP_ENV` doit couvrir `dev` ET `test`** : un container test légitime ne doit pas avoir besoin de secrets prod-grade. `if self.APP_ENV in ("dev", "test"): return self`.
- **`pytest` dans le runtime image, c'est non.** Faire un stage `test` dédié (multi-stage `--target test`) qui étend `deps` + `dev extras` + `tests/`, lancé via `podman run --rm --network <project>_<network>` en éphémère. Le runtime reste minimal en prod.
- **Le test d'intégration "expected tables/FK/CHECK" est le bon filet de sécurité** pour M1+ : il a immédiatement attrapé les fixes du reviewer (le retrait de `ck_mission_test_mitre_tags_exactly_one_mitre_fk` aurait été un oubli silencieux sinon).
- **Lancer le DoD avant de dire "M1 done"** : règle gravée à M0, respectée ici. `make clean && make up && make migrate && make test-api && make e2e` est la séquence canonique de fin de milestone.
## 2026-05-11 — M3 RBAC, groupes, users, invitations
- **`logging.LogRecord` réserve `name`** comme attribut interne (en plus de `message`, `levelname`, `pathname`, `filename`, `module`, `funcName`, `lineno`, `asctime`, `process`, `thread`, `args`). Donc `log.info("metamorph.x.created", extra={"name": entity.name})` lève `KeyError: "Attempt to overwrite 'name' in LogRecord"`. Patron : préfixer toute clé risquée par l'entité (`group_name`, `user_name`, `template_name`). À documenter dans le style guide quand on en aura un.
- **Pattern "sentinel pour distinguer absent vs null"** : Pydantic ne sait pas distinguer `{}` de `{"display_name": null}` quand le champ est `str | None = None`. Solution : lire `raw = request.get_json()` puis tester `"display_name" in raw` dans la couche API, passer un sentinel `...` au service, qui distingue "ne pas toucher" de "set à None". Lourd mais explicite. Si ça revient souvent, encapsuler dans un helper `triState(raw, key, payload)`.
- **`limiter.reset()` flask-limiter** est public et clean — pas besoin de toucher à `limiter._storage`. À appeler dans `/diag/reset` quand le limiter est `enabled`. Toujours guarder avec `if limiter.enabled` pour ne pas planter en `APP_ENV=test`.
- **Rate-limit scope `APP_ENV in ("prod", "staging")`** : meilleure granularité que prod-only. La spec NF-security est *operator-facing*, pas dev. Trade-off réconcilié dans `app/core/rate_limit.py` avec un docstring explicite. Dev = ergonomics totale, prod/staging = limiter actif, test = désactivé.
- **Playwright `workers: 1` + `fullyParallel: false`** quand chaque spec file fait du `/diag/reset` (DB partagée). Avec parallélisme, les workers se truncate mutuellement entre eux → install token consumé, etc. Pattern simple et robuste : un seul worker pour les e2e, parallélisme intra-file laissé à `test.describe.configure({ mode: 'serial' })`.
- **Sessions Playwright entre tests** : chaque `test()` reçoit une `page` neuve (BrowserContext fresh). Pas de partage de session entre tests du même `describe`. Helper `loginViaSpa()` à appeler au début de chaque test SPA-driven (les tests purement API peuvent partager via une variable de spec mais c'est rare). Alternative : `storageState` global, mais ça complique le truncate workflow.
- **Dual seed = boot + bootstrap** : seeder les perms au boot ET dans `bootstrap_admin()` n'est pas redondant. Sur DB fraîchement migrée vide, le boot suffit. Mais après `/diag/reset` (qui TRUNCATE `permissions` + `group_permissions` + `groups`), seul `/setup` re-déclenche le chemin de seed via `bootstrap_admin → seed_all`. Sans ce 2e appel, l'admin créé aurait `is_admin=True` mais le catalogue serait vide.
- **Snapshot UserView/GroupView détachés** : retourner des `@dataclass(frozen=True)` au lieu de l'ORM permet de fermer le `session_scope` immédiatement. Plus simple que `s.expunge()` pour chaque champ, et la couche API peut sérialiser sans lazy-loading. Patron à reproduire pour tous les services.
- **Invariant "admin a toutes les perms"** : même si le décorateur bypass via `is_admin = "admin" in group_names` (et pas via le perm set), garder l'invariant côté API en refusant `set_group_permissions(admin_group, !=all_codes)`. Future-proof : si on bouge le bypass à un check perm-based plus tard, l'invariant tient déjà. `SystemGroupProtected` réutilisé pour le 409.
- **Toujours rebuild front + recreate containers** : `make rebuild` ne recrée pas les containers, donc le bundle nginx reste l'ancien. Patron canonique : `make down && make up`. Documenté pour la 2e fois dans M3 ; à faire passer en runbook au prochain `tasks/testing-m<N>.md`.
## 2026-05-10 — M2 auth, JWT, invitations
- **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`, …) via `email-validator` `globally_deliverable=True`. Pour un outil red-team utilisé en lab/intranet, créer un type custom permissif (`Annotated[str, AfterValidator(...)]`) avec une regex RFC-shape. À garder en tête pour tout futur projet "internal".
- **Cookies `Secure=True` sur `localhost` HTTP** : modern browsers (Chrome ≥89, Firefox ≥75) traitent `localhost` comme un secure context et acceptent les cookies `Secure` même servis en HTTP. Donc on peut respecter la spec strictement (`Secure` toujours) sans casser le dev — pas besoin de gating par `APP_ENV`.
- **`getByLabel` de Playwright** prend le **nom accessible** de l'input. Quand un `<label>` enveloppe `input` + `<span>` hint + `<span>` error, le hint et l'error polluent le nom et `getByLabel('Password', exact: true)` ne matche plus. Pattern correct : `<div>` parent, `<label htmlFor>` séparé du `<input id>`, hint et error en `<p>` siblings hors du `<label>`.
- **`flask-limiter` doit être désactivé en `APP_ENV=test`** sinon les tests qui font 10+ logins de suite rate-limit. `Limiter(..., enabled=settings.APP_ENV != "test")` règle le cas globalement.
- **`pydantic[email]` extra** est REQUIS dès qu'on utilise `EmailStr`. Ne pas s'en rendre compte donne un crash gunicorn worker au boot avec `ImportError: email-validator is not installed`. À dupliquer dans le starter pyproject pour les futurs projets.
- **Compose `target:` est OBLIGATOIRE** quand un Dockerfile a un stage après le runtime — par défaut compose builde le DERNIER stage. J'ai été mordu deux fois (M1 puis M2). Désormais : tout Dockerfile multi-stage avec un stage de test/dev → `target: runtime` explicite dans `docker-compose.yml`.
- **Refresh token rotation + chain revoke** : à chaque `/auth/refresh`, on marque l'ancien token `revoked_at` + `replaced_by_id`. Si quelqu'un re-présente un token déjà rotaté, on cascade-revoke toute la chaîne (compromise probable). Pattern à reproduire pour tout système JWT à long terme.
- **`make rebuild` ne recrée pas les containers** — il faut `make down && make up` après un changement front pour que nginx serve le nouveau bundle. Important quand on debug un test e2e qui attend un selecteur récemment ajouté côté React.
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
<!--
Template for future entries:
## YYYY-MM-DD — short title
- bullet
- bullet
-->

174
tasks/spec.md Normal file
View File

@@ -0,0 +1,174 @@
---
type: spec
date: "2026-05-08"
tags: [spec, ready]
status: ready
project: Metamorph
---
# Metamorph — Spec
> Spec finalisée après tour de questions du 2026-05-08. §12 et §13 vides : prête pour l'exécution. Le tracking quotidien bascule sur `Templates/Project.md`.
## 1. Pitch (3 lignes max)
Plateforme web collaborative purple team : la red team saisit les tests réalisés (procédure, commande, horodatage), la blue team annote en parallèle ses preuves de détection (alertes, logs, fichiers).
À la fin de la mission, Metamorph génère un slide reveal.js synthétisant les tests par catégorie MITRE ATT&CK et leur statut de détection.
Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rôles, avec lien d'invitation et permissions cloisonnées.
## 2. Problème
- Le workflow actuel (Excel → mail → Excel) est fastidieux, non versionné, sans contrôle d'accès, sans cohérence d'horodatage.
- L'horodatage précis et la séparation temporelle entre tests sont critiques pour que la blue team corrèle correctement ses logs.
- Aucune traçabilité des contributions red vs blue, aucune garantie d'intégrité (red peut écraser un commentaire blue).
- Les purple sont récurrents : il faut pouvoir réutiliser des batteries de tests (templates) sans recopier.
## 3. Utilisateurs & cas d'usage
- **Acteurs** : Administrateurs, Red Teamers, Blue Teamers (rôles atomiques par groupe custom — voir §5 F1).
- **Scénarios principaux** :
1. **Admin** crée des tests unitaires (templates) classifiés MITRE ATT&CK et les regroupe en scénarios réutilisables.
2. **Admin** invite des utilisateurs via lien à usage unique, leur assigne un ou plusieurs groupes (perms atomiques).
3. **Red Teamer** crée une mission, l'associe à un client/cible, sélectionne des scénarios, assigne les membres.
4. **Red Teamer** exécute les tests manuellement (sur la machine cible ou via tunnel hors plateforme), saisit dans Metamorph la commande lancée, l'output et un timestamp auto-capturé (overridable).
5. **Blue Teamer** consulte la mission (visibilité whitebox dès le début), annote chaque test : niveau de détection (taxonomie configurable), commentaires markdown, fichiers de preuves (logs, captures, EVTX).
6. **Red Teamer** génère le slide de synthèse reveal.js et l'exporte en PDF.
7. **Utilisateur invité** crée son compte via le lien d'invitation, change son mot de passe, accède aux missions où il est assigné.
8. **Red Teamer** ne peut pas modifier les champs blue (perm `mission.write_blue_fields` absente) et inversement.
## 4. Périmètre
**In scope (MVP v1)**
- Auth locale JWT (access 1h / refresh 30j), Argon2id, min 8 chars.
- Lien d'invitation à usage unique (token URL, expiration 7j, hors mail).
- Bootstrap : token d'install affiché dans les logs au 1er démarrage pour créer le 1er admin via `/setup`.
- Groupes custom + permissions atomiques (familles : user/group/invitation, test_template, scenario_template, mission, mission.write_red_fields, mission.write_blue_fields). 3 groupes pré-seedés : `admin`, `redteam`, `blueteam`.
- CRUD tests unitaires (templates) avec classification MITRE Enterprise (Tactic + Technique + Sub-technique multi-tags).
- CRUD scénarios (groupements ordonnés de tests, drag-and-drop pour la position).
- CRUD missions (nom, client/cible, dates début/fin, membres red+blue assignés, description/ROE markdown, statut `draft → in_progress → completed → archived`).
- Snapshot des templates au moment de l'instanciation dans une mission (modifier un template ne touche pas les missions existantes).
- Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel.
- Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`).
- Workflow par test instance : `pending → executed → reviewed_by_blue` + voies `skipped / blocked`.
- Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures).
- Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints).
- Notifications in-app uniquement (badge + liste), pas de SMTP.
- Génération slide reveal.js standalone (un fichier HTML autoportant) basé sur `tasks/design.md`, avec export PDF côté client (bouton intégré). Catégorisation par défaut MITRE Tactic, regroupement custom optionnel par mission.
- i18n FR + EN avec switch utilisateur.
- Soft delete partout + bouton « purge définitive » admin.
- Export d'une mission : JSON complet (API + UI) et CSV des résultats agrégés.
- Logs JSON structurés sur stdout, niveau configurable via `LOG_LEVEL`.
- Single-tenant + isolation stricte par mission : un utilisateur non-admin ne liste que les missions où il est membre.
**Out of scope v1 — explicitement exclu**
- Tunnel C2/ligolo (binaires, orchestration, exécution distante).
- Intégration Keycloak / OIDC.
- Audit log immuable et versioning des contenus.
- 2FA (TOTP/WebAuthn).
- SMTP / envoi de mail (notifications, invitations).
- Antivirus / scan ClamAV des uploads.
- Multi-tenancy / workspaces.
- Notifications mail.
- Logos / branding personnalisable.
**Nice-to-have — backlog v2+**
- Bascule auth vers Keycloak (OIDC, SSO).
- API d'ingestion pour qu'un C2 externe pousse les résultats automatiquement (hooks d'intégration).
- Audit log détaillé + versioning par champ critique.
- 2FA TOTP self-service.
- Notifications mail optionnelles.
- Intégration des binaires tunnel fournis par l'utilisateur pour l'exécution automatisée.
- Métriques Prometheus.
## 5. Exigences fonctionnelles
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4).
- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus.
- **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires.
- **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation.
- **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.
- **F6** — Saisie côté blue : niveau de détection (enum custom plateforme), commentaires markdown, multi-fichiers (whitelist).
- **F7** — Génération slide reveal.js standalone + export PDF client, groupé par MITRE Tactic (custom optionnel).
- **F8** — Notifications in-app (badge + flux) à chaque transition de statut d'un test concernant l'utilisateur.
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
- **F10** — Soft delete + purge admin.
- **F11** — Switch i18n FR/EN par utilisateur (préférence persistée).
- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti.
## 6. Exigences non fonctionnelles
- **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves.
- **NF-platform** : Debian x64 dernière stable, déploiement docker-compose (api Flask + Postgres + front nginx statique).
- **NF-network** : connectivité requise vers la DB Postgres (réseau interne compose). TLS terminé par un reverse proxy externe (à l'opérateur de la prod). Pas de connectivité sortante requise sauf sync MITRE manuelle.
- **NF-state** : PostgreSQL pour toutes les données structurées (volume Docker `metamorph_db`). Fichiers de preuves stockés sous `/data/evidence/<mission_id>/<test_id>/<sha256>` (volume Docker `metamorph_evidence`). Rétention indéfinie tant que non purgée.
- **NF-observability** : logs JSON sur stdout (champs : ts, level, msg, request_id, user_id, action), `LOG_LEVEL` env. Pas de métriques Prometheus en v1.
- **NF-security** : Argon2id, JWT signés HS256 (clé via env `JWT_SECRET`), CSRF non requis (Bearer token), CORS strict (origin du front uniquement), rate-limit basique sur `/auth/*` (10 req/min/IP). Permissions vérifiées côté serveur sur chaque endpoint, pas seulement côté UI.
- **NF-i18n** : tous les libellés UI passent par un fichier de traduction. Données MITRE conservées en EN (officielles).
## 7. Contraintes techniques
- **Backend** : Python 3.12+, Flask, SQLAlchemy + Alembic (migrations), psycopg2/psycopg3, pyjwt, argon2-cffi, marshmallow ou pydantic v2 pour la validation.
- **Frontend** : React 18 + Vite + TypeScript + TailwindCSS + TanStack Query + react-router. Tokens design (couleurs, typo, espacements de `tasks/design.md`) traduits en `tailwind.config.ts` + composants RTOps réutilisables.
- **Slide** : reveal.js (CDN récupéré et servi en statique par le front), génération côté client à partir des données de l'API.
- **DB** : PostgreSQL 16+.
- **Build** : Linux. Livraison docker-compose (api, db, front-static-nginx). Dockerfile multi-stage par service. Makefile pour `dev`, `build`, `up`, `migrate`, `seed-mitre`.
- **Dépendances JS** : limitées au strict nécessaire ; chaque lib pinned. Bundle Vite, pas de CDN runtime.
- **i18n** : `react-i18next` côté front, `flask-babel` côté back pour les messages d'erreur API.
- **Logs** : `python-json-logger` ou équivalent.
## 8. Entrées / sorties / données
- **Inputs** :
- UI : saisie red (texte), saisie blue (texte + uploads multipart), uploads de fichiers (validation MIME + extension).
- Seed : dataset STIX MITRE ATT&CK Enterprise au premier `up` (ou commande `flask metamorph seed-mitre`).
- **Outputs** :
- Slide reveal.js HTML standalone (un fichier `.html` autoportant, généré côté serveur ou côté client à partir des données API).
- Export JSON mission complet (sans binaires de preuves).
- Export CSV des résultats agrégés (test, mission, statut, niveau détection, timestamp).
- **Modèle de données** (entités principales — détail dans `tasks/todo.md`) :
- `users`, `groups`, `permissions`, `user_groups`, `group_permissions`, `invitations`
- `mitre_tactics`, `mitre_techniques`, `mitre_subtechniques`
- `test_templates`, `scenario_templates`, `scenario_template_tests` (jointure ordonnée)
- `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state d'exécution), `mission_categories` (custom)
- `evidence_files` (FK `mission_test_id`, sha256, mime, size, path)
- `notifications` (in-app)
- `detection_levels` (taxonomie custom, seedée avec 4 niveaux par défaut)
- `settings` (clés plateforme, ex: `mitre_last_sync`)
## 9. Interfaces
- **UI Web** (seule interface utilisateur). Design : `tasks/design.md` strictement (palette, typo JetBrains Mono / IBM Plex Sans, cards bordées par accent, comment-style headings `// Section`).
- **API REST JSON** consommée par le front (préfixe `/api/v1`). Auth Bearer JWT. Endpoints : `/auth/*`, `/users`, `/groups`, `/invitations`, `/test-templates`, `/scenario-templates`, `/missions`, `/missions/:id/tests/:test_id`, `/missions/:id/tests/:test_id/evidence`, `/missions/:id/export.json`, `/missions/:id/export.csv`, `/missions/:id/slide.html`, `/mitre/sync`, `/notifications`, `/detection-levels`, `/settings`. Schéma OpenAPI généré (flask-smorest ou apispec).
- **CLI Flask** (admin opérations) : `flask metamorph create-admin` (fallback), `flask metamorph seed-mitre`, `flask metamorph print-install-token`, `flask metamorph purge-soft-deleted`.
## 10. Critères de succès / Definition of Done
1. Premier boot : token d'install affiché dans les logs, accès `/setup` permet de créer le 1er admin.
2. Admin crée un groupe custom et lui attribue des permissions atomiques (write_red_fields uniquement, par exemple).
3. Admin envoie un lien d'invitation, l'utilisateur s'enregistre avec succès et hérite des bons groupes.
4. Admin importe la matrice MITRE et crée un test unitaire avec Tactic+Technique+Sub-technique.
5. Admin compose un scénario de 3 tests ordonnés via drag-and-drop.
6. Red Teamer crée une mission avec scénario, assigne 1 red + 1 blue.
7. Red Teamer marque un test « exécuté », saisit commande+output, timestamp auto capturé.
8. Blue Teamer voit la notification, annote avec niveau de détection + 2 fichiers de preuves (PDF + .evtx, < 25 Mo).
9. Red Teamer ne peut pas (HTTP 403) écrire dans les champs blue ; idem inverse.
10. Red Teamer génère le slide reveal.js, vérifie le rendu (catégorisation MITRE, accents couleur design.md), exporte en PDF côté navigateur.
11. Admin exporte la mission en JSON et CSV.
12. Admin soft-delete un test ; admin purge définitivement.
13. Switch i18n FR ↔ EN persiste entre sessions.
14. `docker compose up` depuis zéro produit un déploiement fonctionnel sur Debian x64.
15. Logs API en JSON sur stdout, lisibles avec `journalctl`/`docker logs`.
## 11. Risques & inconnues
- **Techniques**
- Génération slide reveal.js « standalone » avec données dynamiques : à valider qu'on inline correctement les données et ressources sans dépendre du back une fois exporté.
- Performances upload preuves multi-fichiers (25 Mo × N) : streaming côté Flask + limite globale par requête à fixer.
- Snapshot vs référence : bien isoler les tables `mission_tests` des `test_templates` à l'instanciation pour ne pas drift.
- **OPSEC** : faible. La plateforme est utilisée en interne avec consentement (purple team avec blue informée).
- **Inconnues levées** : tunnel/C2 reporté en v2, cloisonnement multi-tenant non requis, audit log non requis pour MVP.
## 12. Hypothèses à valider
*(vide — toutes les zones de flou ont été levées par le tour de questions du 2026-05-08)*
## 13. Questions ouvertes pour Claude
*(vide — prêt à passer en exécution)*
---
## Liens
- Project tracking : [[Projects/Metamorph]]
- Design system : `tasks/design.md`
- Plan d'exécution : `tasks/todo.md` (à créer)
- Wiki connexes :
- Troubleshooting :

247
tasks/testing-m0.md Normal file
View File

@@ -0,0 +1,247 @@
---
type: testing
project: Metamorph
milestone: M0
date: "2026-05-10"
---
# Comment tester M0 (bootstrap)
> Procédure de validation manuelle + automatisée pour le milestone M0. Toutes les commandes se lancent depuis la racine du repo.
## 0. Prérequis
Au choix entre Docker **ou** Podman — le Makefile détecte automatiquement (override `ENGINE=docker` ou `ENGINE=podman` si les deux sont installés).
| Outil | Version min | Vérifier |
|--------------------------------|-------------------|-------------------------------------------|
| **Docker Engine** *(option A)* | 24+ | `docker --version` + `docker compose version` |
| **Podman** *(option B)* | 4.0+ avec plugin compose, ou podman-compose 1.0.6+ | `podman --version` + `podman compose version` (ou `podman-compose --version`) |
| GNU make | 4+ | `make --version` |
| curl, jq | n'importe | `curl --version` |
| Node.js (pour les e2e) | 20+ | `node --version` |
Vérifier le moteur que le Makefile utilisera :
```bash
make engine
# ENGINE=podman
# COMPOSE=podman compose
```
Override possible : `make up ENGINE=docker COMPOSE="docker compose"`.
## 1. Bootstrap de l'environnement
```bash
make env # crée .env depuis .env.example
$EDITOR .env # vérifie : APP_ENV=dev OK pour la machine, sinon set des secrets forts
```
Variables critiques de `.env` :
- `APP_ENV``dev` autorise les placeholders ; `prod` ou `staging` exigent `JWT_SECRET >=32 chars` + `POSTGRES_PASSWORD` non-default (sinon l'API refuse de booter).
- `JWT_SECRET` — pour M0 le démon ne signe rien, mais autant le mettre propre tout de suite : `python3 -c "import secrets; print(secrets.token_urlsafe(64))"`.
- `HOST_FRONT_PORT` / `HOST_API_PORT` — modifie si 8080/8000 sont déjà occupés.
## 2. Build & démarrage
```bash
make up # build des 3 images + démarrage
make ps # vérifie que les 3 services tournent
```
**Attendu** :
```
metamorph-db postgres:16-alpine Up (healthy)
metamorph-api metamorph-api Up
metamorph-front metamorph-front Up (healthy)
```
Si `db` reste en `starting` au-delà de 30 s : `make logs` pour voir l'erreur (généralement un mismatch de credentials dans `.env`).
## 3. Tests fonctionnels manuels
### 3.1 — Health API direct (port 8000)
```bash
curl -s http://localhost:8000/api/v1/health | jq
```
**Attendu** :
```json
{ "status": "ok", "version": "0.1.0" }
```
### 3.2 — Health API via le proxy nginx (port 8080)
```bash
curl -s http://localhost:8080/api/v1/health | jq
```
Doit renvoyer le **même** JSON. Cela valide la conf nginx qui proxifie `/api/* → api:8000`.
### 3.3 — SPA dans le navigateur
Ouvrir <http://localhost:8080>. **Vérifications visuelles** :
- [ ] Header centré, fond `#0a0e1a`, titre « Metamorph Purple Team Platform » avec « Meta » en rouge et « Purple Team Platform » en violet.
- [ ] Section `// System Health` avec une card bordée vert affichant `version 0.1.0` et `status: ok`.
- [ ] Section `// Design Tokens` montrant les tags colorés (EVASION, C2, LATERAL…), la flow chain `recon → phish → c2 → lateral → impact`, et 3 boutons.
- [ ] Footer en mono dim avec la mention M0 bootstrap.
- [ ] Toutes les polices sont chargées (titres en JetBrains Mono, body en IBM Plex Sans). Onglet Network : **aucune** requête vers `fonts.googleapis.com` ou `fonts.gstatic.com`.
- [ ] Console JS sans aucune erreur.
### 3.4 — Logs structurés JSON
```bash
make logs-api # tail uniquement le container api (engine-agnostic)
```
**Attendu** : chaque ligne est un objet JSON avec au minimum `ts`, `level`, `logger`, `message`. Exemple :
```json
{"ts":"2026-05-10 14:21:33,012","level":"INFO","logger":"metamorph.boot","message":"metamorph.api.boot","cors_origins":["http://localhost:8080"],"log_level":"INFO","evidence_dir":"/data/evidence"}
```
Si tu vois du texte non-JSON, c'est gunicorn qui parle ; vérifier que l'app est bien chargée via `app.main:app` (le formatter doit s'appliquer).
### 3.5 — Healthchecks containers
```bash
make inspect-health
```
**Attendu** : `healthy` pour les trois containers (à 30 s près après le boot).
```
metamorph-db healthy
metamorph-api healthy
metamorph-front healthy
```
### 3.6 — Garde APP_ENV (sécurité)
Test négatif : on prouve que l'API refuse de booter en non-dev avec un secret faible.
```bash
make down
APP_ENV=prod JWT_SECRET=trop-court $(make engine | sed -n 's/^COMPOSE=//p') up api 2>&1 | head -30
# ou plus simplement, en explicitant ton moteur :
# APP_ENV=prod JWT_SECRET=trop-court docker compose up api
# APP_ENV=prod JWT_SECRET=trop-court podman compose up api
```
**Attendu** : trace d'erreur Pydantic mentionnant *"JWT_SECRET is missing, default, or shorter than 32 chars"*. L'API doit s'arrêter, pas démarrer.
Reset :
```bash
make down && make up
```
### 3.7 — CORS
```bash
curl -is -H 'Origin: http://localhost:8080' http://localhost:8080/api/v1/health \
| grep -i access-control-allow-origin
```
**Attendu** : un header `Access-Control-Allow-Origin: http://localhost:8080`.
```bash
curl -is -H 'Origin: http://evil.example' http://localhost:8080/api/v1/health \
| grep -i access-control-allow-origin || echo "no CORS allow header (expected)"
```
**Attendu** : pas de header (origine non-allowée).
### 3.8 — Volumes persistants
```bash
make volumes
```
**Attendu** : deux volumes nommés (le préfixe peut varier selon le moteur) :
```
metamorph_db
metamorph_evidence
```
Test de persistance basique : `make down && make up` ne doit pas effacer les volumes ; seul `make clean` le fait (destructeur, demande explicite).
## 4. Tests automatisés (Playwright)
```bash
make e2e-install # à faire une seule fois (download chromium + deps OS)
make up # si la stack n'est pas déjà up
make e2e # lance la suite
make e2e-report # ouvre le rapport HTML
```
**Suite M0** (`e2e/tests/m0-smoke.spec.ts`) — 8 tests :
| # | Test | Couvre |
|---|-----------------------------------------------------------|-------------------------------------------------|
| 1 | home page loads and renders the RTOps header | Front + nginx + assets statiques |
| 2 | API health card eventually shows OK | Front → API via proxy `/api/*` |
| 3 | design system primitives render with the expected accents | Card / Tag / FlowNode / Button |
| 4 | body uses self-hosted IBM Plex Sans, no Google Fonts | Spec §7 « pas de CDN runtime » |
| 5 | background uses the RTOps deep navy token | Token `--bg = #0a0e1a` appliqué |
| 6 | no JS console errors on first load | Pas de regression silencieuse côté SPA |
| 7 | API health endpoint returns the expected JSON shape | Contrat API direct |
| 8 | CORS headers are set when the SPA origin asks for them | flask-cors configuré sur `FRONT_ORIGIN` |
Le rapport HTML (`e2e/playwright-report/index.html`) inclut, pour chaque test : steps, screenshots sur échec, vidéo sur retry, trace Playwright (timeline réseau + DOM).
Le rapport JUnit XML (`e2e/playwright-report/junit.xml`) est consommable directement par GitLab CI / GitHub Actions / Jenkins.
## 5. Tests unitaires backend
```bash
make test-api
```
**Attendu** : `tests/test_health.py::test_health_returns_ok PASSED`.
## 6. Lint & typecheck
```bash
make lint
```
Lance ruff (back), eslint + tsc --noEmit (front). Tout doit passer.
## 7. Critères de DoD M0 (extraits de `tasks/todo.md`)
- [ ] `make up` démarre les 3 conteneurs
- [ ] `curl http://localhost:8080/api/v1/health``{"status":"ok","version":"…"}`
- [ ] Front affiche la home RTOps (manuel + e2e #1, #3, #5)
- [ ] Logs JSON sur stdout (manuel #3.4)
- [ ] Volumes nommés présents (manuel #3.8)
- [ ] Suite Playwright M0 verte
- [ ] Rapport HTML disponible dans `e2e/playwright-report/`
## 8. Si quelque chose casse
| Symptôme | Diagnostic |
|-------------------------------------------------------|---------------------------------------------------------|
| `make up` plante en build du back | Probablement un download `uv` lent ; relancer ou `make rebuild` |
| API réponse 502 via le front | api pas encore healthy ; `make logs api` |
| Page blanche, console : `Failed to load module …` | Le bundle Vite n'a pas été produit ; `make rebuild` |
| Polices custom non chargées (fallback sans-serif visible) | Vérifier que `@fontsource/*` est bien dans `node_modules` du build context |
| Tests Playwright `Timeout … API health card` | API pas joignable depuis le navigateur ; tester `curl` d'abord |
| `make volumes` ne montre rien | Vérifier que la stack est `make up`. Sous Podman rootless, les volumes vivent dans `~/.local/share/containers/storage/volumes/`. |
| `make engine` annonce le mauvais moteur | Override : `make up ENGINE=docker COMPOSE="docker compose"` ou inverse pour podman. |
| `podman compose` indisponible mais `podman-compose` oui | Le Makefile fallback automatiquement, ou force-le : `COMPOSE=podman-compose make up`. |
## 9. Pièges connus (validés sur podman 5.x / Fedora 43)
- **Short-name resolution sous Podman** : si tu remplaces une image par son nom court (`postgres:16-alpine`), Podman échoue avec `short-name resolution enforced but cannot prompt without a TTY`. **Toujours utiliser `docker.io/library/<image>:<tag>`** (Docker accepte le préfixe transparente).
- **Premier `make up`** : compte ~3 min pour télécharger `postgres:16-alpine` + builder les images custom. Les builds suivants sont quasi instantanés grâce au cache.
- **`make inspect-health` montre `(no-healthcheck)` malgré le Dockerfile** : podman-compose 1.x ne propage pas les healthchecks du Dockerfile. Le projet redéclare les healthchecks dans `docker-compose.yml` pour cette raison.
- **`api` reste en `starting` ~15 s** avant de basculer healthy : c'est le `start_period: 10s` du healthcheck + 1 round de polling. Normal.
- **Volumes Podman rootless** : `~/.local/share/containers/storage/volumes/` au lieu de `/var/lib/docker/volumes/`. `make volumes` liste les bons volumes peu importe l'engine.
## 10. Teardown
```bash
make down # garde les volumes
make clean # supprime aussi les volumes (DESTRUCTEUR)
```

284
tasks/todo.md Normal file
View File

@@ -0,0 +1,284 @@
---
type: todo
date: "2026-05-08"
tags: [todo, plan]
status: in_progress
project: Metamorph
spec: tasks/spec.md
---
# Metamorph — Plan d'implémentation
> Découpage en 14 milestones livrables indépendamment. Chaque milestone a une **DoD** vérifiable. Cocher au fil de l'eau, documenter les écarts dans `CHANGELOG.md`, retours d'expérience dans `tasks/lessons.md`.
## Convention
- ☐ = à faire · ☑ = fait · ⚠ = bloqué (commenter) · ↻ = en cours
- Branches : `feature/m<N>-<slug>` · commits : `feat(m<N>): …` / `fix(m<N>): …`
- Chaque PR doit : passer lint/typecheck, mettre à jour `CHANGELOG.md`, mettre à jour `README.md` si surface utilisateur.
- **Chaque milestone livre un fichier `tasks/testing-m<N>.md`** (procédure manuelle + automatisée) **et au moins un spec Playwright `e2e/tests/m<N>-*.spec.ts`**.
- À la fin de chaque milestone : lancer le subagent `spec-reviewer` (HARD RULE 4 du CLAUDE.md global) avant de marquer le milestone done.
---
## M0 — Bootstrap repo & infra ☐
**But** : squelette buildable de bout en bout sans aucune feature métier.
-`backend/` (Flask 3, Python 3.12, `pyproject.toml` avec uv ou poetry, structure `app/{api,core,db,models,services,i18n}`)
-`frontend/` (Vite + React 18 + TS strict, Tailwind 3, ESLint + Prettier, alias `@/`)
- ☐ Tokens design `tasks/design.md` traduits en `frontend/tailwind.config.ts` (palette CSS vars, typo JetBrains Mono / IBM Plex Sans, radii 3/4/6/10).
- ☐ Composants UI de base : `<Card>`, `<Tag>`, `<SectionHeader>` (avec `// `), `<FlowNode>`, `<Button>` — fidèles au design.
-`docker-compose.yml` : services `api`, `db` (postgres:16-alpine), `front` (nginx servant le bundle Vite).
- ☐ Dockerfile multi-stage par service ; volumes nommés `metamorph_db`, `metamorph_evidence`.
-`Makefile` : `dev`, `build`, `up`, `down`, `migrate`, `seed-mitre`, `lint`, `test`.
- ☐ Pré-commit hook : `ruff` (back), `eslint`+`tsc --noEmit` (front).
-`README.md` minimal (run en dev, run en prod, variables d'env attendues).
-`.gitignore` : `.env`, `*.exe`, `*.dll`, `__pycache__/`, `node_modules/`, `dist/`, `data/`.
-`.env.example` documenté (`POSTGRES_*`, `JWT_SECRET`, `LOG_LEVEL`, `FRONT_ORIGIN`).
- ☐ Logs JSON structurés sur stdout (`python-json-logger`).
**DoD** : `make up` démarre les 3 conteneurs ; `curl http://localhost:${HOST_FRONT_PORT:-8080}/api/v1/health` renvoie `{ "status": "ok", "version": "..." }` (proxifié par nginx via `api:8000`) ; le front sur `:8080` affiche une page d'accueil au design RTOps ; **`make e2e` passe les 8 tests Playwright** ; rapport HTML dans `e2e/playwright-report/`. Procédure complète : `tasks/testing-m0.md`. *En prod, la TLS est terminée par un reverse proxy externe (cf. spec §6 NF-network) — la stack compose ne sert que du HTTP.*
---
## M1 — Schéma DB & migrations Alembic ☐
**But** : modèle de données complet versionné, sans logique métier.
- ☐ Configurer SQLAlchemy 2.x + Alembic.
- ☐ Tables auth/RBAC : `users`, `groups`, `permissions`, `user_groups`, `group_permissions`, `invitations`, `refresh_tokens`.
- ☐ Tables MITRE : `mitre_tactics`, `mitre_techniques`, `mitre_subtechniques` (avec `external_id`, `name`, `description`, `url`).
- ☐ Tables templates : `test_templates`, `test_template_mitre_tags` (jointure many-to-many tactic/technique/subtechnique), `scenario_templates`, `scenario_template_tests` (avec `position`).
- ☐ Tables missions : `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state), `mission_test_mitre_tags`, `mission_categories` (custom).
- ☐ Tables exécution : `evidence_files` (FK `mission_test_id`, `sha256`, `mime`, `size_bytes`, `storage_path`, `original_filename`).
- ☐ Tables paramétrage : `detection_levels` (clé, label_fr, label_en, color_token, position, is_default), `settings` (key/value).
- ☐ Table notifications : `notifications` (FK user, type, payload JSONB, read_at, created_at).
- ☐ Soft delete : colonne `deleted_at` partout sauf tables jointures simples ; index partiel `WHERE deleted_at IS NULL`.
- ☐ Audit minimal : `created_at`, `updated_at` partout.
- ☐ Migration initiale Alembic + commande `make migrate`.
**DoD** : `make migrate` applique le schéma sur une DB vide ; `\dt` montre toutes les tables ; les contraintes FK et les index sont en place.
---
## M2 — Auth, bootstrap, invitations ☑
**But** : un humain peut s'inscrire et se connecter.
- ☐ Hash mot de passe : `argon2-cffi` (params modérés, `time_cost=2, memory_cost=64MB`).
- ☐ JWT : `pyjwt`, HS256, claims `sub`, `iat`, `exp`, `type` (access|refresh), `jti`. Access 1h, refresh 30j.
- ☐ Stockage refresh tokens en DB (rotation à chaque usage, révocation au logout).
- ☐ Endpoints : `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`, `GET /auth/me`, `POST /auth/change-password`.
- ☐ Bootstrap : commande `flask metamorph print-install-token` génère + persiste un token unique au 1er démarrage (si table `users` vide), écrit dans les logs au boot.
- ☐ Endpoint `POST /setup` : consomme le token d'install, crée le 1er admin (groupe `admin` seedé).
- ☐ Invitations : `POST /invitations` (admin, génère token 7j), `GET /invitations/{token}` (preview), `POST /invitations/{token}/accept` (création compte avec password choisi).
- ☐ Middleware d'auth Flask (`@require_auth`, `@require_perm("...")`).
- ☐ Rate-limit `flask-limiter` sur `/auth/*` (10/min/IP).
- ☐ Front : pages `/login`, `/setup`, `/register?token=…`, `/profile`. Stockage access en mémoire, refresh en cookie HTTPOnly Secure SameSite=Strict.
- ☐ Hook React `useAuth()` + interceptor TanStack Query (refresh auto sur 401).
- ☐ CORS strict (origin `FRONT_ORIGIN`).
**DoD** : `flask metamorph print-install-token` → /setup → création admin → login → /auth/me OK ; admin crée invitation → user s'inscrit via lien → login OK ; `/auth/refresh` renouvelle correctement.
---
## M3 — RBAC : groupes, permissions, gestion users ☑
**But** : admin peut composer des groupes custom et y assigner des users.
- ☐ Seed des permissions atomiques (familles spec §4) :
- `user.{read,create,update,delete}`, `group.{read,create,update,delete}`, `invitation.{create,revoke,read}`
- `test_template.{read,create,update,delete}`, `scenario_template.{read,create,update,delete}`
- `mission.{read,create,update,archive,delete}`, `mission.write_red_fields`, `mission.write_blue_fields`
- `detection_level.{read,update}`, `setting.{read,update}`, `mitre.sync`
- ☐ Seed des 3 groupes par défaut (`admin` = toutes, `redteam` = templates(read) + missions(read,create,update) + write_red_fields, `blueteam` = templates(read) + missions(read) + write_blue_fields).
- ☐ Endpoints CRUD `groups`, `permissions` (lecture seule), `users` (admin), `users/{id}/groups` (assign).
- ☐ Décorateur `@require_perm` qui vérifie l'union des perms via tous les groupes du user.
- ☐ Front : page Admin > Users (liste, recherche, modale d'édition des groupes), Admin > Groups (CRUD + multi-select des perms), Admin > Invitations (liste, créer, révoquer).
- ☐ UI : on n'affiche pas les actions interdites (mais le serveur reste l'arbitre).
**DoD** : un admin peut créer un groupe `pentest-2026-Q2` avec uniquement `mission.read` + `mission.write_red_fields`, l'attribuer à Bob ; Bob voit les missions auxquelles il est membre mais ne peut pas écrire dans les champs blue (HTTP 403 au niveau API).
---
## M4 — MITRE ATT&CK Enterprise ☐
**But** : le référentiel ATT&CK est interrogeable et tagué sur les tests.
- ☐ Téléchargement initial du STIX bundle Enterprise depuis `github.com/mitre/cti` (vérifier hash, pin une version).
- ☐ Parser STIX → tables `mitre_tactics` / `mitre_techniques` / `mitre_subtechniques` (extraire `external_id` ATT&CK, `name`, `description`, `url`, relations technique↔tactic).
- ☐ Commande `flask metamorph seed-mitre [--source <path|url>]`.
- ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`).
- ☐ Persister `mitre_last_sync` dans `settings`.
- ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`).
- ☐ Front : composant `<MitreTagPicker>` (autocomplete combiné Tactic > Technique > Sub-technique, multi-select).
**DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.
---
## M5 — Templates : tests unitaires & scénarios ☐
**But** : admin peut bâtir le catalogue réutilisable.
- ☐ Modèle `test_template` : nom, description, objectif, procédure (markdown), prérequis (markdown), résultat attendu red, détection attendue blue, niveau OPSEC (`enum low/med/high`), tags libres (array text), IOCs attendus (array text), tags MITRE (multi).
- ☐ Endpoints CRUD `/test-templates` avec validation pydantic.
- ☐ Modèle `scenario_template` : nom, description, liste ordonnée de tests (`position`).
- ☐ Endpoints CRUD `/scenario-templates`, `PUT /scenario-templates/{id}/tests` (réordonnancement).
- ☐ Front : page Admin > Tests (liste filtrable par tactic / OPSEC / tag), modale d'édition (form complet avec markdown editor — `@uiw/react-md-editor` ou équivalent léger).
- ☐ Front : page Admin > Scénarios, drag-and-drop avec `@dnd-kit/sortable`.
- ☐ Filtres : recherche full-text sur nom/desc, facettes MITRE/OPSEC/tags.
**DoD** : admin crée 5 tests + 1 scénario de 3 tests réordonnés ; recharge la page → ordre persistant ; suppression soft-delete d'un template n'efface pas les scénarios.
---
## M6 — Missions & snapshot ☐
**But** : transformer les templates en missions vivantes.
- ☐ Modèle `mission` : nom, client/cible (texte), date_start, date_end, status (`enum draft/in_progress/completed/archived`), description (markdown), `visibility_mode` figé à `whitebox` v1.
-`mission_members` : (mission_id, user_id, role_hint `red|blue`) — rôle hint informatif, l'autorisation reste portée par les permissions.
- ☐ Lors de la création/modification d'une mission, sélection de scénarios → **snapshot** : copie complète des `scenario_templates` et `test_templates` dans `mission_scenarios` / `mission_tests` (y compris tags MITRE).
-`mission_tests` ajoute : `state` (`enum pending/executed/reviewed_by_blue/skipped/blocked`), `executed_at` (nullable), `executed_at_override` (bool), `red_command`, `red_output`, `red_comment`, `blue_comment`, `detection_level_id` (nullable).
- ☐ Endpoints : `POST /missions`, `GET /missions` (filtré par perms + membership pour les non-admin), `GET /missions/{id}` (avec scénarios+tests), `PUT /missions/{id}` (métadonnées + ajout de scénarios → snapshot), `POST /missions/{id}/transition` (drift de status), `DELETE /missions/{id}` (soft).
- ☐ Front : page Missions (liste + filtres status/client/dates), création (wizard 3 étapes : meta → scénarios → membres), vue mission (header + onglets Tests / Membres / Synthèse / Export).
- ☐ Vue mission : tableau des tests avec colonnes Tactic | Test | Statut | Niveau de détection | Last update, actions selon perms.
**DoD** : red crée une mission avec 1 scénario de 3 tests, ajoute Alice (red) et Bob (blue) ; modification ultérieure d'un test_template ne change rien dans la mission (snapshot préservé).
---
## M7 — Saisie red & blue sur un test ☐
**But** : exécution de la mission, le cœur du produit.
- ☐ Modale ou page dédiée `Mission > Test #N` avec deux zones distinctes (red / blue), bordures accentuées par couleur (rouge / cyan).
- ☐ Côté red : champ commande (mono), output (mono multiline), commentaire markdown, bouton « Marquer exécuté » qui set `state=executed` + `executed_at=now()` ; édition de `executed_at` derrière un toggle « override ».
- ☐ Côté blue : sélecteur `detection_level`, commentaire markdown, zone d'upload multi-fichiers (drag-and-drop).
- ☐ Upload preuves : `POST /missions/{id}/tests/{test_id}/evidence` (multipart, validation extension+MIME+taille≤25Mo, calcul SHA256, stockage `/data/evidence/<mission_id>/<test_id>/<sha256>{ext}`).
-`GET /evidence/{id}` (download, vérif perm) ; `DELETE /evidence/{id}` (soft).
- ☐ Permissions : tout endpoint d'écriture vérifie `mission.write_red_fields` ou `mission.write_blue_fields` selon le champ touché ; les deux peuvent coexister sur un même groupe (pas exclusifs en code).
- ☐ Bouton « Statut » avec choix `executed`, `reviewed_by_blue`, `skipped`, `blocked` (transitions contrôlées : pending↔skipped/blocked, executed→reviewed_by_blue).
- ☐ Indicateur « modifié par X il y a Ns » : polling `GET /missions/{id}/activity?since=…` toutes les 15 s tant que la page est active.
**DoD** : red et blue saisissent en parallèle sans conflit ; un user sans `write_blue_fields` reçoit 403 sur les champs blue ; un fichier .evtx de 24 Mo est uploadé, un de 26 Mo est rejeté ; le hash SHA256 est correct.
---
## M8 — Niveaux de détection custom ☐
**But** : la taxonomie d'icônes du slide est paramétrable.
- ☐ Seed initial : `detected_blocked` (red), `detected_alert` (orange), `logged_only` (yellow), `not_detected` (rose).
- ☐ Endpoints `/detection-levels` : list, create, update (label_fr, label_en, color_token, position, is_default).
- ☐ Garde-fou : empêcher la suppression si utilisé dans des `mission_tests` (proposer désactivation).
- ☐ Front : page Admin > Settings > Detection Levels (table + modale, picker de color_token parmi les 10 accents du design).
**DoD** : admin renomme `not_detected``missed`, ajoute `false_positive` avec accent purple ; les missions existantes affichent les nouveaux libellés ; un blueteamer voit la nouvelle option dans le sélecteur.
---
## M9 — Notifications in-app ☐
**But** : red et blue savent quand l'autre a agi.
- ☐ Service `notify(user_id, type, payload)` appelé sur transitions clés : `test_executed`, `test_reviewed_by_blue`, `evidence_added`, `mission_status_changed`.
- ☐ Endpoints `GET /notifications?unread_only=…`, `POST /notifications/{id}/read`, `POST /notifications/read-all`.
- ☐ Front : badge dans le header avec compteur, dropdown listant les 20 dernières + lien vers la mission/test.
- ☐ Polling `GET /notifications?unread_only=true` toutes les 30 s (ou WebSocket plus tard, hors scope).
**DoD** : Bob (blue) reçoit un badge « Test #4 prêt à review » 30 s max après qu'Alice (red) clique « Marquer exécuté ».
---
## M10 — Génération du slide reveal.js ☐
**But** : livrable client de fin de mission.
- ☐ Backend : endpoint `GET /missions/{id}/slide.html` qui calcule l'agrégat (tests groupés par MITRE Tactic, comptages par detection_level, plus regroupements custom si configurés).
- ☐ Côté serveur, on émet **un seul fichier HTML standalone** : reveal.js inliné (CSS + JS), tokens design.md inlinés, données JSON inlinées, **aucune ressource externe**.
- ☐ Layout : slide titre, slide « Méthodologie », une slide par Tactic avec liste des techniques/tests + icône colorée par detection_level, slide synthèse (matrice tactic × detection_level), slide annexes (preuves référencées par titre, sans binaires).
- ☐ Bouton « Export PDF » dans le slide → `print-pdf` reveal.js (`window.print()` + media query reveal).
- ☐ Front : page Mission > Synthèse avec preview iframe + bouton « Télécharger HTML ».
- ☐ Conformité design : `// ` headings en cyan, accents par detection_level, JetBrains Mono partout.
**DoD** : on télécharge `mission-X.html`, on l'ouvre offline dans Firefox/Chrome, navigation reveal OK, export PDF côté navigateur produit un PDF lisible.
---
## M11 — Exports JSON & CSV ☐
**But** : sortie des données brutes pour archivage.
-`GET /missions/{id}/export.json` : mission + scénarios + tests + niveaux de détection + métadonnées preuves (sans binaires, mais avec hash + filename).
-`GET /missions/{id}/export.csv` : une ligne par test (cols : test_name, mitre_tactic, mitre_technique, mitre_subtechnique, executed_at, status, red_command, detection_level, blue_comment_excerpt).
- ☐ Front : boutons d'export sur la page mission, headers `Content-Disposition: attachment`.
**DoD** : `curl -OJ` sur les deux endpoints donne deux fichiers cohérents et complets ; le JSON peut être réimporté dans le futur (laisser cet import en backlog).
---
## M12 — Soft delete & purge admin ☐
**But** : aucune perte de donnée par accident, ménage explicite.
- ☐ Toutes les `DELETE` du back deviennent `UPDATE deleted_at`.
- ☐ Tous les `GET` filtrent `deleted_at IS NULL` par défaut, paramètre `?include_deleted=true` réservé aux admins.
- ☐ Endpoint `POST /admin/purge` (perm admin) avec body `{entity, ids}` qui DELETE physiquement (suppression fichiers preuves incluse).
- ☐ Commande `flask metamorph purge-soft-deleted --older-than 30d` (manuelle, pas de cron auto).
- ☐ Front : page Admin > Trash (filtrée par entity), bouton « Restaurer » + bouton « Purger ».
**DoD** : suppression d'un test depuis l'UI → disparait des listes mais reste en DB ; admin peut le restaurer ; admin peut le purger définitivement, le fichier evidence associé disparait du disque.
---
## M13 — i18n FR / EN ☐
**But** : commutation de langue par utilisateur.
- ☐ Backend : `flask-babel`, deux locales `fr` / `en`. Messages d'erreur API via `gettext`. Fichier `messages.pot` extrait via `pybabel extract`.
- ☐ Frontend : `react-i18next`, namespaces par page, fichiers `frontend/src/i18n/{fr,en}/*.json`.
- ☐ Préférence user : champ `users.locale` (default `fr`), endpoint `PATCH /auth/me {locale}`, switch dans le header.
- ☐ Données MITRE conservées en EN (officielles, non traduites).
- ☐ Tous les libellés UI passent par `t('…')` — interdit le texte en dur.
**DoD** : Bob change sa langue en EN, recharge → toute l'UI en EN sauf les noms ATT&CK ; un message d'erreur API arrive aussi en EN.
---
## M14 — Polish, sécu, observabilité, doc ☐
**But** : prêt pour livraison.
- ☐ Logs JSON : `request_id`, `user_id`, `path`, `method`, `status`, `duration_ms`, `action` (libre côté service).
- ☐ Audit minimal : logger toute action sensible (`auth.login`, `mission.create`, `evidence.delete`, `admin.purge`).
- ☐ Rate-limit confirmé sur `/auth/*` et `/invitations/*`.
- ☐ Headers sécu : `Strict-Transport-Security` (si reverse proxy le pose, sinon doc), `Content-Security-Policy` strict côté front, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
- ☐ Validation : tailles max body globale (Flask `MAX_CONTENT_LENGTH`), schéma pydantic strict partout.
-`README.md` complet (déploiement, env, premier admin, sync MITRE, backup volumes).
-`CHANGELOG.md` à jour (Conventional changelog).
- ☐ Critères §10 de la spec : check 1 par 1 sur une démo end-to-end documentée dans `tasks/lessons.md`.
- ☐ Tests : pytest pour la logique critique (auth, RBAC, snapshot, upload, exports). Smoke E2E Playwright (non bloquant, but nice).
**DoD** : démo from-scratch sur Debian 13 — `git clone``make up` → setup admin → invite users → crée mission → exécute → annote → génère slide → export. Tous les 15 critères §10 spec validés.
---
## Backlog v2+ (rappel pour ne pas oublier)
- Bascule auth Keycloak/OIDC.
- API d'ingestion C2 externe (push automatique des résultats).
- Audit log détaillé + versioning par champ.
- 2FA TOTP self-service.
- Notifications mail.
- Intégration tunnel C2 (binaires fournis).
- Métriques Prometheus.
- Multi-tenancy / workspaces.
- Branding configurable (logos, couleurs).
---
## Hygiène de session
- Au début de chaque session : relire `tasks/lessons.md`, `CHANGELOG.md`, ce fichier.
- À la fin : mettre à jour les ☐/☑, ajouter une entrée `CHANGELOG.md`, capturer les apprentissages dans `tasks/lessons.md`.
- Pour tout doute architectural : repasser par AskUserQuestion avant d'ouvrir un éditeur.