Milestone 3
This commit is contained in:
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)
|
||||
```
|
||||
218
tasks/testing-m1.md
Normal file
218
tasks/testing-m1.md
Normal file
@@ -0,0 +1,218 @@
|
||||
---
|
||||
type: testing
|
||||
project: Metamorph
|
||||
milestone: M1
|
||||
date: "2026-05-10"
|
||||
---
|
||||
|
||||
# Comment tester M1 (schéma DB & migrations)
|
||||
|
||||
> Procédure de validation manuelle + automatisée pour M1 : SQLAlchemy 2 + Alembic + 26 tables. Toutes les commandes se lancent depuis la racine du repo.
|
||||
|
||||
## 0. Prérequis
|
||||
|
||||
Voir `tasks/testing-m0.md §0` (Docker ou Podman, Make, Node 20+, etc.). Aucune dépendance Python locale n'est requise — pytest tourne dans un container éphémère bâti depuis le stage `test` du Dockerfile backend.
|
||||
|
||||
## 1. Bootstrap (si la stack n'est pas déjà up)
|
||||
|
||||
```bash
|
||||
make env # crée .env si absent
|
||||
make up # build + start de la stack (api / db / front)
|
||||
make inspect-health # attends que les 3 soient healthy
|
||||
```
|
||||
|
||||
## 2. Appliquer la migration
|
||||
|
||||
```bash
|
||||
make migrate # alembic upgrade head dans le container api
|
||||
make migrate-status # confirme la revision courante = head
|
||||
```
|
||||
|
||||
**Attendu** :
|
||||
```
|
||||
INFO [alembic.runtime.migration] Will assume transactional DDL.
|
||||
INFO [alembic.runtime.migration] Running upgrade -> 24765a5014b6, initial schema
|
||||
```
|
||||
|
||||
et `make migrate-status` :
|
||||
```
|
||||
24765a5014b6 (head)
|
||||
---
|
||||
24765a5014b6 (head)
|
||||
```
|
||||
|
||||
## 3. Tests fonctionnels manuels
|
||||
|
||||
### 3.1 — Liste des tables
|
||||
|
||||
```bash
|
||||
make psql # ouvre psql dans le container db
|
||||
\dt # une fois dans psql
|
||||
```
|
||||
|
||||
**Attendu** : **27 lignes** (26 tables métier + `alembic_version`) :
|
||||
|
||||
```
|
||||
detection_levels, evidence_files, group_permissions, groups,
|
||||
invitation_groups, invitations, mission_categories, mission_members,
|
||||
mission_scenarios, mission_test_mitre_tags, mission_tests, missions,
|
||||
mitre_subtechniques, mitre_tactics, mitre_technique_tactics,
|
||||
mitre_techniques, notifications, permissions, refresh_tokens,
|
||||
scenario_template_tests, scenario_templates, settings,
|
||||
test_template_mitre_tags, test_templates, user_groups, users,
|
||||
alembic_version
|
||||
```
|
||||
|
||||
Compter via SQL :
|
||||
```bash
|
||||
podman exec metamorph-db psql -U metamorph -d metamorph -tAc \
|
||||
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'"
|
||||
# 27
|
||||
```
|
||||
|
||||
### 3.2 — Contraintes au niveau Postgres
|
||||
|
||||
```bash
|
||||
podman exec metamorph-db psql -U metamorph -d metamorph -tAc \
|
||||
"SELECT contype, count(*) FROM pg_constraint WHERE connamespace = 'public'::regnamespace GROUP BY contype ORDER BY contype"
|
||||
```
|
||||
|
||||
**Attendu** :
|
||||
- `c|9` — CHECK constraints (status valid, opsec_level valid, mitre_kind valid, exactly_one_mitre_fk uniquement sur `test_template_mitre_tags`, …)
|
||||
- `f|32` — Foreign keys *(snapshot `mission_test_mitre_tags` n'a volontairement pas de FK MITRE)*
|
||||
- `p|27` — Primary keys (1 par table)
|
||||
- `u|14` — UNIQUE constraints
|
||||
|
||||
### 3.3 — Index partiels (soft delete + unread notifications)
|
||||
|
||||
```bash
|
||||
podman exec metamorph-db psql -U metamorph -d metamorph -c \
|
||||
"SELECT indexname FROM pg_indexes WHERE schemaname='public' AND indexdef ILIKE '%WHERE%' ORDER BY 1"
|
||||
```
|
||||
|
||||
**Attendu** (12 indexes) :
|
||||
- `ix_evidence_files_active`, `ix_groups_active`, `ix_missions_active`, `ix_mission_categories_active`, `ix_mission_scenarios_active`, `ix_mission_tests_active`, `ix_scenario_templates_active`, `ix_test_templates_active`, `ix_users_active` — soft-delete partiels (9)
|
||||
- `ix_notifications_user_unread` — `WHERE read_at IS NULL`
|
||||
- `uq_users_email_active`, `uq_groups_name_active` — uniques scopés aux lignes actives
|
||||
|
||||
### 3.4 — Test négatif d'un CHECK constraint (`exactly_one_mitre_fk`)
|
||||
|
||||
```sql
|
||||
-- Dans psql, doit échouer :
|
||||
INSERT INTO test_templates (id, name, opsec_level)
|
||||
VALUES (gen_random_uuid(), 'tmp', 'low');
|
||||
INSERT INTO mitre_tactics (id, external_id, short_name, name)
|
||||
VALUES (gen_random_uuid(), 'TA0099', 'tmp', 'tmp');
|
||||
-- (puis tente une insertion avec deux FK MITRE non null — bloqué par CHECK)
|
||||
```
|
||||
|
||||
Couvert automatiquement par `tests/test_schema.py::test_exactly_one_mitre_fk_check_enforced`.
|
||||
|
||||
### 3.5 — Migration depuis DB totalement vide
|
||||
|
||||
```bash
|
||||
make clean # DESTRUCTEUR — supprime aussi les volumes
|
||||
make up # re-spawn db vierge
|
||||
# attends que db soit healthy
|
||||
make migrate # applique 0001_initial sur DB vide
|
||||
podman exec metamorph-db psql -U metamorph -d metamorph -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'"
|
||||
# attendu : 27
|
||||
```
|
||||
|
||||
## 4. Tests automatisés
|
||||
|
||||
### 4.1 — Backend pytest (M1 schema integration)
|
||||
|
||||
```bash
|
||||
make test-api
|
||||
```
|
||||
|
||||
**Attendu** : `9 passed in <1s` (1 health M0 + 8 schema M1).
|
||||
|
||||
Détail des 8 tests M1 :
|
||||
|
||||
| # | Test | Couvre |
|
||||
|---|---|---|
|
||||
| 1 | `test_all_expected_tables_exist` | Liste exhaustive des 26 tables métier |
|
||||
| 2 | `test_soft_delete_columns_present` | `deleted_at` sur 6 tables |
|
||||
| 3 | `test_standard_timestamp_columns_present` | `created_at`+`updated_at` sur 5 tables |
|
||||
| 4 | `test_partial_index_for_soft_delete` | Index `ix_<table>_active` partiel |
|
||||
| 5 | `test_expected_foreign_keys` | 14 paires FK clés (red→users, blue→users, evidence→test, etc.) |
|
||||
| 6 | `test_expected_check_constraints` | Les 10 CHECK constraints fonctionnelles |
|
||||
| 7 | `test_alembic_at_head` | `SELECT version_num FROM alembic_version` non-vide |
|
||||
| 8 | `test_exactly_one_mitre_fk_check_enforced` | Test négatif INSERT — viole le CHECK |
|
||||
|
||||
Le test runner s'appuie sur le stage `test` du Dockerfile backend (`--target test` avec uv et les dev extras), spawné en container éphémère sur le réseau du compose. Le runtime stays minimal.
|
||||
|
||||
### 4.2 — Suite e2e Playwright (M0 + M1)
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
```
|
||||
|
||||
**Attendu** : `12 passed`. Détail :
|
||||
- 8 tests M0 (smoke bootstrap)
|
||||
- 4 tests M1 (`e2e/tests/m1-db.spec.ts`) :
|
||||
1. `GET /api/v1/diag/db` renvoie une revision Alembic en hex et `table_count >= 26`
|
||||
2. La home page rend la card « Database » avec le short-hash de la revision et le compteur
|
||||
3. La card « Roadmap » indique « M0 + M1 done » et cite M2
|
||||
4. Le footer mentionne `M0 bootstrap` + `M1 db schema`
|
||||
|
||||
Le rapport HTML est dans `e2e/playwright-report/`.
|
||||
|
||||
### 4.3 — Endpoint diagnostique direct
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8080/api/v1/diag/db | jq
|
||||
```
|
||||
|
||||
**Attendu** :
|
||||
```json
|
||||
{
|
||||
"reachable": true,
|
||||
"alembic_revision": "24765a5014b6",
|
||||
"table_count": 27
|
||||
}
|
||||
```
|
||||
|
||||
Quand la DB est down (ex : `make down` sur le service `db` seul), l'endpoint renvoie `503` avec `{"reachable": false, "error": "database_unreachable"}`.
|
||||
|
||||
## 5. Génération d'une nouvelle migration (workflow dev)
|
||||
|
||||
```bash
|
||||
# 1. Modifier un modèle dans backend/app/models/
|
||||
# 2. Générer la migration via Alembic dans le container :
|
||||
make migrate-revision MSG="add foo column to mission_tests"
|
||||
|
||||
# Le fichier est créé dans le container — copie-le sur l'host pour le commit :
|
||||
podman cp metamorph-api:/app/alembic/versions/. backend/alembic/versions/
|
||||
|
||||
# 3. Relire le fichier généré, le formatter (`make fmt`)
|
||||
# 4. Rebuild + apply :
|
||||
make rebuild && make up && make migrate
|
||||
```
|
||||
|
||||
## 6. DoD M1 — checklist (extraits de `tasks/todo.md`)
|
||||
|
||||
- [x] `make migrate` applique le schéma sur DB vide
|
||||
- [x] `\dt` montre les 27 tables (26 métier + alembic_version)
|
||||
- [x] FK + CHECK + indexes en place (32 FK / 9 CHECK / 14 UQ / 12 partial)
|
||||
- [x] Naming convention Alembic stable (préfixes `pk_/fk_/ck_/uq_/ix_`)
|
||||
- [x] Soft delete partout sauf jointures simples (`deleted_at` + index partiel)
|
||||
- [x] Audit minimal (`created_at`/`updated_at`) sur les tables principales
|
||||
- [x] Tests d'intégration pytest verts (9 passed)
|
||||
- [x] M0 e2e ne régresse pas
|
||||
|
||||
## 7. Pièges connus
|
||||
|
||||
- **`COMPOSE` cible le dernier stage du Dockerfile par défaut** : si on ajoute un stage après `runtime` (ici `test`), il faut explicitement `target: runtime` dans `docker-compose.yml`. Sinon `make up` lance pytest au lieu de gunicorn — le container exit en boucle.
|
||||
- **Alembic autogenerate dans le container** : le fichier est créé dans `/app/alembic/versions/` du container. Le récupérer sur l'host via `podman cp` avant rebuild, sinon perdu.
|
||||
- **Post-write hook `ruff`** : retiré d'`alembic.ini` parce que ruff est dev-only et n'est pas dans l'image runtime. Formatter les migrations à la main avec `make fmt` après génération.
|
||||
- **`change-me-strong` (placeholder de `.env.example`)** est rejeté par `model_validator` en `APP_ENV=prod`. Pour les tests on a élargi le bypass à `APP_ENV in ("dev", "test")`.
|
||||
|
||||
## 8. Teardown
|
||||
|
||||
```bash
|
||||
make down # garde les volumes
|
||||
make clean # supprime aussi les volumes (DESTRUCTEUR)
|
||||
```
|
||||
212
tasks/testing-m2.md
Normal file
212
tasks/testing-m2.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
type: testing
|
||||
project: Metamorph
|
||||
milestone: M2
|
||||
date: "2026-05-10"
|
||||
---
|
||||
|
||||
# Comment tester M2 (auth, bootstrap, invitations)
|
||||
|
||||
> Procédure de validation manuelle + automatisée pour M2 (auth JWT, /setup, invitations, RBAC). Toutes les commandes se lancent depuis la racine.
|
||||
|
||||
## 0. Prérequis
|
||||
|
||||
Voir `tasks/testing-m0.md §0`. M2 n'ajoute aucune dépendance host (le pytest tourne dans un container éphémère via `make test-api`).
|
||||
|
||||
## 1. Bootstrap stack vide → premier admin
|
||||
|
||||
```bash
|
||||
make env
|
||||
make up
|
||||
make migrate # 27 tables (M1)
|
||||
```
|
||||
|
||||
Récupère le token install dans les logs :
|
||||
```bash
|
||||
make logs-api | grep -E "INSTALL TOKEN" | tail -1
|
||||
# ou : podman logs metamorph-api 2>&1 | grep "INSTALL TOKEN"
|
||||
```
|
||||
|
||||
Si la stack a été utilisée et le token consommé, force-mint un nouveau :
|
||||
```bash
|
||||
make print-install-token-force
|
||||
```
|
||||
|
||||
## 2. /setup — création du 1er admin via curl
|
||||
|
||||
```bash
|
||||
TOKEN="<paste-token-here>"
|
||||
curl -s -X POST http://localhost:8080/api/v1/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"install_token\":\"$TOKEN\",\"email\":\"admin@metamorph.local\",\"password\":\"AdminPass1234!\"}" | jq
|
||||
# Attendu : {"ok":true,"user_id":"..."}
|
||||
```
|
||||
|
||||
Vérifie que `/setup` ne peut plus être consommé :
|
||||
```bash
|
||||
curl -s http://localhost:8080/api/v1/setup | jq
|
||||
# Attendu : {"completed":true}
|
||||
```
|
||||
|
||||
## 3. /setup via la SPA
|
||||
|
||||
Ouvrir http://127.0.0.1:8080/setup dans le navigateur. Le formulaire affiche :
|
||||
- **Install token** (paste depuis les logs)
|
||||
- **Admin email**
|
||||
- **Display name (optional)**
|
||||
- **Password** + **Confirm password** (≥ 8 chars)
|
||||
|
||||
Le bouton « Create admin » appelle `POST /api/v1/setup` puis redirige vers `/login`. Si la stack a déjà un admin, la page affiche « Already done · Go to login → ».
|
||||
|
||||
## 4. Login + /auth/me
|
||||
|
||||
```bash
|
||||
LOGIN=$(curl -s -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}')
|
||||
echo "$LOGIN" | jq
|
||||
ACCESS=$(echo "$LOGIN" | jq -r .access_token)
|
||||
|
||||
curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq
|
||||
# Attendu : {is_admin: true, groups: ["admin"], email: "...", ...}
|
||||
```
|
||||
|
||||
Le cookie `metamorph_refresh` est dans `/tmp/cookies.txt` (HTTPOnly, scope `/api/v1/auth/`).
|
||||
|
||||
Côté SPA : `/login` → email + password → redirige vers `/`. Le header affiche l'email courant via le testid `me-email` (`<span data-testid="me-email">…</span>`).
|
||||
|
||||
## 5. Refresh rotation
|
||||
|
||||
```bash
|
||||
curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/refresh | jq
|
||||
# Attendu : nouveau access_token, le cookie refresh est rotaté
|
||||
```
|
||||
|
||||
L'ancien refresh token devient `revoked_at IS NOT NULL` en base ; un 2e usage du même refresh révoque la chaîne entière (détection de réutilisation).
|
||||
|
||||
## 6. Invitation → register → 2e login
|
||||
|
||||
```bash
|
||||
# Admin crée invitation
|
||||
INV=$(curl -s -X POST http://localhost:8080/api/v1/invitations \
|
||||
-H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
|
||||
-d '{"email_hint":"alice@metamorph.local"}')
|
||||
echo "$INV" | jq
|
||||
INV_TOKEN=$(echo "$INV" | jq -r .token)
|
||||
|
||||
# Preview (anonyme)
|
||||
curl -s "http://localhost:8080/api/v1/invitations/preview/$INV_TOKEN" | jq
|
||||
|
||||
# Acceptance
|
||||
curl -s -X POST "http://localhost:8080/api/v1/invitations/accept/$INV_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq
|
||||
|
||||
# Login Alice
|
||||
curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token | head -c 40
|
||||
```
|
||||
|
||||
Côté SPA : ouvrir `http://127.0.0.1:8080/register?token=<INV_TOKEN>`. La page affiche le hint email, demande password + confirm, redirige vers `/login` après acceptance.
|
||||
|
||||
## 7. RBAC — non-admin reçoit 403
|
||||
|
||||
```bash
|
||||
ALICE_ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token)
|
||||
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:8080/api/v1/invitations \
|
||||
-H "Authorization: Bearer $ALICE_ACCESS" -H "Content-Type: application/json" -d '{}'
|
||||
# Attendu : HTTP 403
|
||||
```
|
||||
|
||||
## 8. Change password (force logout-all)
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/api/v1/auth/change-password \
|
||||
-H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
|
||||
-d '{"current_password":"AdminPass1234!","new_password":"AdminPass5678!"}' | jq
|
||||
# Attendu : {"ok":true}
|
||||
```
|
||||
|
||||
Tous les refresh tokens du user sont révoqués → toute autre session existante repasse par /login.
|
||||
|
||||
## 9. Profile (SPA)
|
||||
|
||||
Une fois loggué, ouvrir `/profile` :
|
||||
- Carte **Identity** (email, display name, locale)
|
||||
- Carte **Groups** (tags)
|
||||
- Carte **Permissions** (admin → tag « ADMIN — bypasses checks »)
|
||||
- Carte **Change password** (current + new + confirm → submit → redirige vers /login après 1.5 s)
|
||||
|
||||
Logout via le bouton du header → redirige vers `/login`. Tenter `/profile` sans session → redirige vers `/login`.
|
||||
|
||||
## 10. Tests automatisés
|
||||
|
||||
### 10.1 — Backend pytest
|
||||
|
||||
```bash
|
||||
make test-api
|
||||
```
|
||||
|
||||
**Attendu** : **24 passed** (1 health + 8 schema + 15 auth flow).
|
||||
|
||||
Détail M2 (`tests/test_auth_flow.py`) :
|
||||
| Test | Couvre |
|
||||
|---|---|
|
||||
| setup_status_starts_uncompleted | /setup état initial |
|
||||
| setup_creates_first_admin | bootstrap consomme le token, crée admin |
|
||||
| setup_status_now_completed | idempotence |
|
||||
| setup_replay_is_blocked | 409 si déjà fait |
|
||||
| login_and_me | flux login → /me complet |
|
||||
| login_with_wrong_password_returns_401 | aucune énumération |
|
||||
| me_without_token_returns_401 | bearer requis |
|
||||
| refresh_rotates_and_old_token_is_revoked | rotation chaîne |
|
||||
| refresh_with_no_cookie_returns_401 | cookie obligatoire |
|
||||
| logout_clears_cookie_and_is_idempotent | idempotence |
|
||||
| admin_creates_invitation_and_invitee_accepts | flux complet |
|
||||
| unauthenticated_cannot_create_invitation | auth requis |
|
||||
| non_admin_cannot_create_invitation | RBAC 403 |
|
||||
| used_invitation_cannot_be_accepted_twice | usage unique |
|
||||
| change_password_revokes_all_refresh_tokens | logout-all |
|
||||
|
||||
### 10.2 — Playwright e2e
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
```
|
||||
|
||||
**Attendu** : **20 passed** (8 M0 + 4 M1 + 8 M2).
|
||||
|
||||
Détail M2 (`e2e/tests/m2-auth.spec.ts`) :
|
||||
| Test | Couvre |
|
||||
|---|---|
|
||||
| setup status is uncompleted before bootstrap | API contract |
|
||||
| SPA setup form creates the first admin | UI /setup |
|
||||
| SPA login works and reveals the profile page | UI /login + /profile |
|
||||
| admin issues an invitation via the API and the front renders the registration form | UI /register?token=… |
|
||||
| invitee submits the registration form and can log in | UI register accept |
|
||||
| non-admin gets 403 on the admin invitations endpoint | RBAC |
|
||||
| refresh token rotation works through the SPA | rotation |
|
||||
| logout clears the session and redirects to login | logout |
|
||||
|
||||
Le rapport HTML : `e2e/playwright-report/index.html`. JUnit : `e2e/playwright-report/junit.xml`.
|
||||
|
||||
## 11. Pièges connus (M2 spécifiques)
|
||||
|
||||
- **Cookie `Secure` en HTTP** : `secure=True` fait rejeter le cookie par le browser sur HTTP. Métamorph utilise `secure = APP_ENV in ("prod","staging")` — en `dev`/`test` le cookie est non-secure. En prod, le reverse proxy externe doit terminer la TLS.
|
||||
- **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`) avec `globally_deliverable=True`. Métamorph utilise un type `Email` permissif via regex (cf. `app/api/_validation.py`).
|
||||
- **`getByLabel` Playwright** récupère le **nom accessible** de l'input. Si la `<label>` enveloppe l'input ET le hint, le hint pollue le nom → matchs ratés. Le composant `TextField` met le hint en sibling, pas à l'intérieur du `<label>`.
|
||||
- **Rate-limit `/auth/login` (10/min)** : peut bloquer une suite de tests. `flask-limiter` est désactivé par construction quand `APP_ENV=test`.
|
||||
- **`/diag/reset`** est exposé seulement quand `APP_ENV in ("dev","test")`. Truncate les tables auth + force-mint un nouveau token install. **Ne jamais activer en prod.**
|
||||
- **Compose recreate** : `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.
|
||||
|
||||
## 12. Teardown
|
||||
|
||||
```bash
|
||||
make down
|
||||
# ou full reset (DESTRUCTEUR) :
|
||||
make clean
|
||||
```
|
||||
102
tasks/testing-m3.md
Normal file
102
tasks/testing-m3.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
type: testing
|
||||
milestone: M3
|
||||
date: "2026-05-11"
|
||||
project: Metamorph
|
||||
---
|
||||
|
||||
# Testing M3 — RBAC, groups, users, invitations
|
||||
|
||||
## 1. Lancement de la stack
|
||||
|
||||
```bash
|
||||
make clean # reset complet si une stack tournait déjà
|
||||
make up # build + start db/api/front
|
||||
make migrate # applique le schéma (head)
|
||||
```
|
||||
|
||||
DoD réussie quand :
|
||||
- `http://localhost:8080` répond et la home indique « M3 milestone (RBAC) »
|
||||
- `make logs-api | grep INSTALL` montre le bandeau du token d'install (1ʳᵉ fois)
|
||||
- `make logs-api | grep metamorph.permissions.seeded` confirme `perms_total: 31` au boot
|
||||
|
||||
## 2. Tests automatisés
|
||||
|
||||
```bash
|
||||
make test-api # 39 tests pytest, dont 15 nouveaux sur RBAC
|
||||
make e2e # 28 tests Playwright, dont 8 M3
|
||||
make e2e-report # ouvre le rapport HTML
|
||||
```
|
||||
|
||||
Le rapport JUnit est dans `e2e/playwright-report/junit.xml`.
|
||||
|
||||
## 3. Procédure manuelle (smoke navigateur)
|
||||
|
||||
### Pré-requis
|
||||
1. Stack up via `make up`.
|
||||
2. `make print-install-token` → noter le token (ou `make logs-api` pour le bandeau).
|
||||
|
||||
### 3.1 Bootstrap admin
|
||||
1. Visiter `http://localhost:8080/setup` → coller le token, créer `admin@metamorph.local` / `AdminPass1234!`.
|
||||
2. Redirection auto vers `/login` après ~1.5 s.
|
||||
3. Se connecter avec les mêmes identifiants.
|
||||
4. La barre de nav fait apparaître les liens **Users / Groups / Invitations** (visibles uniquement quand `is_admin === true`).
|
||||
|
||||
### 3.2 Page Groups (`/admin/groups`)
|
||||
1. Cliquer **+ New group** → modale `new group`.
|
||||
2. Nom : `pentest-2026-Q2`, description libre.
|
||||
3. Cocher uniquement `mission.read` et `mission.write_red_fields` dans la grille de permissions.
|
||||
4. **Create group** → la carte apparaît dans la liste avec les badges `2 perms`.
|
||||
5. Ouvrir le groupe `admin` (système) — le champ `Name` doit être désactivé et la mention « System groups cannot be renamed. » visible.
|
||||
6. Tenter de supprimer le groupe `admin` → bouton **Delete** invisible (les groupes système sont protégés côté UI **et** serveur).
|
||||
|
||||
### 3.3 Page Invitations (`/admin/invitations`)
|
||||
1. Cliquer **+ New invitation** → modale `new invitation`.
|
||||
2. Email hint : `alice@metamorph.local`. Cocher `pentest-2026-Q2`.
|
||||
3. **Generate link** → modale `invitation link` montrant l'URL one-shot ; bouton **Copy** disponible.
|
||||
4. Coller l'URL dans un onglet privé → page `/register` pré-remplie avec l'email hint. Compléter le mot de passe, valider.
|
||||
|
||||
### 3.4 Page Users (`/admin/users`)
|
||||
1. Liste les comptes existants. Chercher `alice` dans le champ **Search**.
|
||||
2. Cliquer **Edit** sur Alice → modale `alice@metamorph.local`. Vérifier ses groupes (cochés : `pentest-2026-Q2`).
|
||||
3. Décocher `pentest-2026-Q2`, cocher `redteam`. **Save changes**.
|
||||
4. Recharger la page → Alice a maintenant le badge `redteam`.
|
||||
|
||||
### 3.5 Last-admin protection
|
||||
1. Tenter de soft-delete l'admin courant via l'API :
|
||||
```bash
|
||||
ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token)
|
||||
MY_ID=$(curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq -r .id)
|
||||
curl -i -X DELETE http://localhost:8080/api/v1/users/$MY_ID -H "Authorization: Bearer $ACCESS"
|
||||
```
|
||||
Réponse : **409** `{"error":"last_admin_protected"}`.
|
||||
|
||||
### 3.6 Vérification RBAC côté serveur (non-admin)
|
||||
1. Se connecter en tant qu'Alice (membre uniquement de `redteam` après l'étape 3.4) :
|
||||
```bash
|
||||
ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"alice@metamorph.local","password":"<son password>"}' | jq -r .access_token)
|
||||
```
|
||||
2. `/users` → **403** : `redteam` n'a pas `user.read`.
|
||||
3. `POST /groups` → **403** : `redteam` n'a pas `group.create`.
|
||||
4. Naviguer dans le browser sur `/admin/users` → redirige vers `/` (RequireAdmin client-side).
|
||||
|
||||
### 3.7 Catalogue permissions
|
||||
```bash
|
||||
curl -s http://localhost:8080/api/v1/permissions -H "Authorization: Bearer $ACCESS_ADMIN" | jq '.items | length'
|
||||
```
|
||||
Doit retourner `31` (cf. `app/services/permissions_seed.py:PERMISSION_CATALOGUE`).
|
||||
|
||||
## 4. Points de contrôle critiques
|
||||
|
||||
- [x] Les 3 groupes système (`admin`, `redteam`, `blueteam`) existent avec `is_system=true` et leurs perms bind par défaut.
|
||||
- [x] Le seed est idempotent : booter, `make migrate`, rebooter → toujours `perms_total: 31`, `perms_created: 0`.
|
||||
- [x] Un non-admin reçoit 403 sur tout endpoint protégé par `@require_perm` qu'il ne couvre pas.
|
||||
- [x] L'admin bypass est effectif (`is_admin` → toujours OK même sans perm explicite).
|
||||
- [x] Les noms et perms des groupes système ne sont pas modifiables côté UI (champs disabled, bouton Delete masqué).
|
||||
- [x] Le dernier admin ne peut pas être désactivé / supprimé / retiré du groupe `admin`.
|
||||
- [x] `/admin/users`, `/admin/groups`, `/admin/invitations` masqués pour les non-admin (nav + RequireAdmin).
|
||||
- [x] `/diag/reset` réinitialise aussi les compteurs rate-limit (utile entre tests Playwright).
|
||||
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