diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml new file mode 100644 index 0000000..5c5a13e --- /dev/null +++ b/.github/workflows/dast.yml @@ -0,0 +1,160 @@ +name: DAST + +# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre +# la base et le backend sur le runner, crée un compte `lecteur` jetable (scripts/dast-token.sh), +# puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte. +# +# Non bloquant pour l'instant : le volume d'alertes d'un premier passage est inconnu. Le rapport +# est publié en artefact et dans le résumé du job. Le fixer en seuil viendra une fois les alertes +# triées. +# +# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas +# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et +# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui. + +on: + workflow_dispatch: + schedule: + # Un scan actif est long : hebdomadaire plutôt qu'à chaque PR. + - cron: "0 3 * * 1" + pull_request: + # Ne se lance sur une PR que si le scan lui-même change. + paths: + - ".github/workflows/dast.yml" + - "scripts/dast-token.sh" + +permissions: + contents: read + +concurrency: + group: dast-${{ github.ref }} + cancel-in-progress: true + +jobs: + zap: + name: Scan OWASP ZAP de l'API + runs-on: ubuntu-latest + timeout-minutes: 60 + + # Même image que docker-compose.yml : la première migration refuse de s'appliquer sans + # l'extension TimescaleDB (cf. backend.yml). + services: + db: + image: timescale/timescaledb-ha:pg17 + env: + POSTGRES_USER: enervision + POSTGRES_PASSWORD: change_me + POSTGRES_DB: enervision_dast + ports: + - "5433:5432" + options: >- + --health-cmd "pg_isready -U enervision -d enervision_dast" + --health-interval 10s + --health-timeout 5s + --health-retries 12 + --health-start-period 40s + + env: + # Base jetable : ZAP y écrira et le script y crée deux comptes. + DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast + APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur + APP_ENV: local + # Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut). + # 3600 est le plafond accepté par la configuration. + APP_ACCESS_TOKEN_TTL_SECONDS: "3600" + PGPASSWORD: change_me + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + working-directory: apps/backend + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --frozen --no-dev + working-directory: apps/backend + + - name: Active TimescaleDB sur la base du scan + run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb" + + - name: Applique les migrations + run: uv run alembic upgrade head + working-directory: apps/backend + + - name: Démarre l'API + run: | + nohup uv run uvicorn app.main:create_app --factory --host 0.0.0.0 --port 8000 \ + > "$RUNNER_TEMP/api.log" 2>&1 & + for _ in $(seq 1 30); do + curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0 + sleep 2 + done + echo "L'API ne répond pas sur /health/ready" >&2 + cat "$RUNNER_TEMP/api.log" >&2 + exit 1 + working-directory: apps/backend + + - name: Crée le compte lecteur du scan + id: jeton + run: | + jeton="$(../../scripts/dast-token.sh)" + echo "::add-mask::$jeton" + echo "jeton=$jeton" >> "$GITHUB_OUTPUT" + working-directory: apps/backend + + # `--network host` : ZAP atteint l'API sur le localhost du runner. Le dossier de sortie doit + # être inscriptible par l'utilisateur du conteneur (uid 1000). + # + # Les routes d'authentification qui changent l'état du compte du scan sont exclues : un + # scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de + # passe et la fermeture des sessions, sans rien apprendre de plus. + - name: Scan ZAP + id: zap + continue-on-error: true + run: | + mkdir -p zap-out && chmod 777 zap-out + docker run --rm --network host -v "$PWD/zap-out:/zap/wrk:rw" \ + ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \ + -t http://localhost:8000/openapi.json -f openapi \ + -T 30 \ + -r zap-report.html -J zap-report.json -w zap-report.md \ + -z "-config replacer.full_list(0).description=auth \ + -config replacer.full_list(0).enabled=true \ + -config replacer.full_list(0).matchtype=REQ_HEADER \ + -config replacer.full_list(0).matchstring=Authorization \ + -config replacer.full_list(0).regex=false \ + -config replacer.full_list(0).replacement='Bearer ${{ steps.jeton.outputs.jeton }}' \ + -config globalexcludeurl.url_list.url(0).description=auth-etat \ + -config globalexcludeurl.url_list.url(0).enabled=true \ + -config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*'" + + - name: Publie le résumé + if: always() + run: | + if [ -f zap-out/zap-report.md ]; then + cat zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY" + else + echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Publie les rapports + if: always() + uses: actions/upload-artifact@v4 + with: + name: zap-report + path: zap-out/ + if-no-files-found: warn + + # Un scan sans compte authentifié ne testerait que les routes publiques : mieux vaut le + # dire que le laisser passer pour vert. + - name: Journal de l'API en cas d'échec + if: failure() || steps.zap.outcome == 'failure' + run: cat "$RUNNER_TEMP/api.log" || true diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 4fcd851..f8a290d 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -59,7 +59,7 @@ flowchart TB ## Déclenchement -Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : +Les workflows de qualité se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, `airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline @@ -187,12 +187,48 @@ run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploi déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par l'issue #22. +## Scan DAST (OWASP ZAP) + +Statut : `En cours`. Le workflow `dast.yml` attaque l'API **en fonctionnement**, ce que ni Bandit, +ni `pip-audit`, ni Sonar ne font. Il se lance à la main (`workflow_dispatch`), chaque lundi à 3h +UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan actif dure plusieurs +minutes. + +Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base +jetable) et le backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son +jeton. ZAP charge le contrat `/openapi.json` (`zap-api-scan.py -f openapi`) et envoie ce jeton +dans l'en-tête `Authorization`. Sans lui, ZAP ne verrait que les deux sondes et `/auth/login`. + +Trois décisions à savoir défendre : + +- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait + `POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin + jetable pour créer le lecteur (l'API n'a pas d'inscription publique) puis ne s'en sert plus. +- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le + mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant + de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`. +- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut + dure 15 minutes et le scan bien plus. + +Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`, +`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la +limitation de débit et fermeraient les sessions sans rien apprendre de plus. + +**Non bloquant pour l'instant** (`continue-on-error`). Le volume d'alertes d'un premier passage est +inconnu ; le rapport HTML/JSON/Markdown est publié en artefact `zap-report` et dans le résumé du +job. Fixer un seuil viendra une fois les alertes triées. + +**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`, +pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy +(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un +second passage sur la stack complète reste à faire. + ## Ce qui manque, et pourquoi | Manque | Issue | Conséquence assumée | |---|---|---| | Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine | -| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances | +| DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées | | Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI | | Tests de charge | #47 | Aucun garde-fou de performance | | Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés | diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 96df248..7202cfa 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -38,6 +38,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints | Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 | | Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 | | Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 | +| Scan dynamique OWASP ZAP de l'API authentifiée (compte `lecteur` jetable), non bloquant, configuration par défaut du backend uniquement (ni TLS ni en-têtes du reverse proxy) | `.github/workflows/dast.yml`, `scripts/dast-token.sh` | A05, API8 Security Misconfiguration | | En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | | Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design | | Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 | diff --git a/scripts/README.md b/scripts/README.md index 9395406..848aeb4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,3 +1,11 @@ # Scripts Outillage local du monorepo. Les taches courantes passent par le `Makefile` racine. + +## dast-token.sh + +Prépare le scan DAST (`.github/workflows/dast.yml`) : sur une API déjà démarrée, crée un compte +`lecteur` jetable, lui fait passer le changement de mot de passe obligatoire et écrit son jeton +d'accès sur la sortie standard. À lancer depuis `apps/backend`, contre une base **jetable** (il y +crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`, +`jq` et `openssl`. diff --git a/scripts/dast-token.sh b/scripts/dast-token.sh new file mode 100755 index 0000000..fdbe4a3 --- /dev/null +++ b/scripts/dast-token.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Prépare le scan DAST : crée un compte `lecteur` sur une API déjà démarrée, lui fait passer le +# changement de mot de passe obligatoire, et écrit son jeton d'accès sur la sortie standard. +# +# Piège : un compte neuf est en `must_change_password`, et toute route gardée le refuse tant que +# le mot de passe n'a pas été changé. Sans cette étape, ZAP ne verrait que 403 sur les routes +# gardées et le scan ne testerait rien de l'API authentifiée. +# +# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin +# frapperait POST /users ou la réinitialisation de mots de passe pour de bon. +# +# L'administrateur n'existe que pour créer ce compte (l'API n'a pas d'inscription publique). +# À lancer depuis apps/backend, dans un environnement où DATABASE_URL et APP_SECRET_KEY visent +# une base JETABLE : le script y crée deux comptes. + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8000}" +API="$BASE_URL/api/v1" +SUFFIXE="$(openssl rand -hex 4)" +EMAIL_ADMIN="dast-admin-$SUFFIXE@enervision.fr" +EMAIL_LECTEUR="dast-lecteur-$SUFFIXE@enervision.fr" + +# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial. +nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; } + +# Tout ce qui n'est pas la sortie finale part sur stderr : la sortie standard ne porte que le jeton. +journal() { echo "dast-token: $*" >&2; } + +connexion() { + curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg e "$1" --arg p "$2" '{email:$e, password:$p}')" | jq -r '.access_token' +} + +changer_mot_de_passe() { + local jeton="$1" ancien="$2" nouveau="$3" + curl -fsS -o /dev/null -X POST "$API/auth/password" \ + -H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" +} + +journal "création de l'administrateur $EMAIL_ADMIN" +SORTIE="$(uv run python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)" +MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")" +[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie"; exit 1; } + +JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")" +NOUVEAU_ADMIN="$(nouveau_mot_de_passe)" +changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN" +# Le changement de mot de passe ferme les sessions : le jeton précédent ne vaut plus rien. +JETON="$(connexion "$EMAIL_ADMIN" "$NOUVEAU_ADMIN")" + +journal "création du lecteur $EMAIL_LECTEUR" +REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")" +MDP_TEMPORAIRE="$(jq -r '.temporary_password' <<<"$REPONSE")" + +JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")" +NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)" +changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR" +JETON="$(connexion "$EMAIL_LECTEUR" "$NOUVEAU_LECTEUR")" + +# Vérifie que le jeton ouvre bien une route gardée avant de le rendre. +CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")" +[[ "$CODE" == "200" ]] || { journal "GET /sites répond $CODE avec le jeton du lecteur, attendu 200"; exit 1; } + +journal "jeton du lecteur prêt" +echo "$JETON"