ci(backend): ajoute un scan DAST OWASP ZAP de l'API avec un compte lecteur jetable*
This commit is contained in:
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Executable
+69
@@ -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"
|
||||
Reference in New Issue
Block a user