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:
31
.env.example
Normal file
31
.env.example
Normal 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
46
.gitignore
vendored
Normal 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
46
.pre-commit-config.yaml
Normal 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
150
CHANGELOG.md
Normal 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`; 10–20/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
214
Makefile
Normal 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
129
README.md
Normal 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
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
85
backend/Dockerfile
Normal file
85
backend/Dockerfile
Normal 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
39
backend/README.md
Normal 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
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Metamorph backend API package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
14
backend/app/api/health.py
Normal file
14
backend/app/api/health.py
Normal 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
24
backend/app/api/v1.py
Normal 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)
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
76
backend/app/core/config.py
Normal file
76
backend/app/core/config.py
Normal 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()
|
||||||
34
backend/app/core/logging.py
Normal file
34
backend/app/core/logging.py
Normal 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)
|
||||||
0
backend/app/i18n/__init__.py
Normal file
0
backend/app/i18n/__init__.py
Normal file
72
backend/app/main.py
Normal file
72
backend/app/main.py
Normal 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
60
backend/pyproject.toml
Normal 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
82
docker-compose.yml
Normal 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
12
e2e/.eslintrc.cjs
Normal 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
5
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
playwright/.cache
|
||||||
|
*.log
|
||||||
66
e2e/README.md
Normal file
66
e2e/README.md
Normal 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
1841
e2e/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
e2e/package.json
Normal file
26
e2e/package.json
Normal 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
60
e2e/playwright.config.ts
Normal 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
127
e2e/tests/m0-smoke.spec.ts
Normal 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
18
e2e/tsconfig.json
Normal 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
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
18
frontend/.eslintrc.cjs
Normal file
18
frontend/.eslintrc.cjs
Normal 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
8
frontend/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal 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
39
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
45
frontend/nginx.conf
Normal 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
43
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
85
frontend/src/App.tsx
Normal file
85
frontend/src/App.tsx
Normal 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;
|
||||||
45
frontend/src/components/ui/Button.tsx
Normal file
45
frontend/src/components/ui/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/components/ui/Card.tsx
Normal file
50
frontend/src/components/ui/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/ui/FlowNode.tsx
Normal file
37
frontend/src/components/ui/FlowNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/ui/SectionHeader.tsx
Normal file
51
frontend/src/components/ui/SectionHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/ui/Tag.tsx
Normal file
37
frontend/src/components/ui/Tag.tsx
Normal 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
49
frontend/src/index.css
Normal 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
19
frontend/src/lib/cn.ts
Normal 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
14
frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
185
frontend/src/pages/HomePage.tsx
Normal file
185
frontend/src/pages/HomePage.tsx
Normal 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 & 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&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
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
66
frontend/tailwind.config.ts
Normal file
66
frontend/tailwind.config.ts
Normal 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;
|
||||||
29
frontend/tsconfig.app.json
Normal file
29
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
18
frontend/tsconfig.node.json
Normal file
18
frontend/tsconfig.node.json
Normal 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
29
frontend/vite.config.ts
Normal 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
376
tasks/design.md
Normal 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.10–0.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 1–2 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.10–0.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
76
tasks/lessons.md
Normal 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
174
tasks/spec.md
Normal 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
247
tasks/testing-m0.md
Normal 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
284
tasks/todo.md
Normal 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.
|
||||||
Reference in New Issue
Block a user