fix(ci,backend): securise le jeton du scan DAST, seme des donnees et refait ses garde-fous
This commit is contained in:
@@ -331,8 +331,10 @@ pas prise :
|
||||
| `license_info` | Aucune licence n'est choisie |
|
||||
| `contact` | Aucun canal de support n'existe |
|
||||
|
||||
Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et
|
||||
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement
|
||||
Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
|
||||
`CookieRafraichissement` pour `/auth/refresh` et `/auth/logout`, des noms ASCII délibérés (issue
|
||||
#41 : un outillage tiers comme ZAP peut mal analyser un nom de schéma accentué dans le contrat).
|
||||
**Le second est purement
|
||||
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
|
||||
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
|
||||
401 sur lequel le frontend déclenche sa déconnexion.
|
||||
|
||||
@@ -57,13 +57,21 @@ flowchart TB
|
||||
push --> fd
|
||||
push --> mv & ms
|
||||
push --> av & ab
|
||||
push --> sb1 & sb2 --> sscan
|
||||
push --> sb1 & sb2 & sb3 --> sscan
|
||||
|
||||
subgraph cd["Déploiement · deploy.yml"]
|
||||
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
|
||||
end
|
||||
|
||||
push -->|"push sur dev ou main"| dep
|
||||
|
||||
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
|
||||
subgraph dastw["DAST · dast.yml"]
|
||||
zscan["zap<br/>seed + scan actif OWASP ZAP"]
|
||||
end
|
||||
|
||||
planifie --> zscan
|
||||
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
|
||||
```
|
||||
|
||||
## Déclenchement
|
||||
@@ -97,7 +105,7 @@ environnement.
|
||||
|
||||
## Déploiement
|
||||
|
||||
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
`deploy.yml` est l'un des sept workflows, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
||||
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
||||
entrant n'est ouvert.
|
||||
@@ -250,11 +258,20 @@ UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan act
|
||||
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`.
|
||||
jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
|
||||
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
|
||||
`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
|
||||
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
|
||||
|
||||
Trois décisions à savoir défendre :
|
||||
ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
|
||||
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
|
||||
contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les
|
||||
réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que
|
||||
de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`,
|
||||
`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et
|
||||
répondent donc pareil avec ou sans lui.
|
||||
|
||||
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
|
||||
@@ -262,42 +279,57 @@ Trois décisions à savoir défendre :
|
||||
- **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`.
|
||||
`POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans
|
||||
`app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert
|
||||
directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux
|
||||
allers-retours de refresh-token de moins sur le chemin critique de la CI.
|
||||
- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut
|
||||
dure 15 minutes et le scan bien plus.
|
||||
dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en
|
||||
dessous, marge comprise pour les étapes qui l'entourent.
|
||||
- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker
|
||||
run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque
|
||||
du runner pour toute la durée de l'étape ; le second serait visible par `ps aux` et par
|
||||
`docker inspect zap` tant que le conteneur existe. Il est écrit dans un fichier de
|
||||
configuration ZAP séparé (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne
|
||||
jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque
|
||||
`-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et
|
||||
`zap-stdout.log` publiées en artefact sont donc caviardées avant publication.
|
||||
|
||||
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.
|
||||
|
||||
**Un scan vert n'est pas un scan qui a testé quelque chose.** Au premier passage, le job était vert
|
||||
alors que ZAP n'avait importé que **2 URL sur 26 opérations** du contrat (`Number of Imported URLs:
|
||||
2`) : il n'avait envoyé que des requêtes vouées au 404, sans jamais atteindre une route gardée
|
||||
(rapport : 100 % de réponses 4xx, zéro alerte). ZAP « réussit » dans ce cas. Le job porte donc un
|
||||
garde-fou qui, lui, **bloque** : il échoue si moins de 10 URL sont importées. Le journal interne de
|
||||
ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans l'artefact `zap-report`
|
||||
(dossier `zap-logs/`) pour diagnostiquer un import raté.
|
||||
**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** :
|
||||
|
||||
Diagnostic du premier passage : `zap-api-scan.py` appelle `importUrl` sur `/openapi.json`, ZAP répond
|
||||
**400**, le contrat n'est pas chargé et ZAP se rabat sur l'exploration de la racine. Le job charge
|
||||
donc le contrat **depuis un fichier** (`-t /zap/wrk/openapi.json -O http://localhost:8000`) et
|
||||
renomme dans cette copie, sans toucher au contrat versionné, les deux schémas de sécurité aux noms
|
||||
accentués (`Jeton d'accès`, `Cookie de rafraîchissement`) que l'analyseur de ZAP peut refuser. La
|
||||
cause exacte du 400 n'est pas confirmée : si l'import échoue encore, `zap-logs/zap.log` la donne.
|
||||
Deuxième diagnostic (contrat importé, 81 endpoints) : **toutes** les requêtes de ZAP recevaient un 400
|
||||
`Invalid HTTP request received` d'uvicorn, y compris `/api/v1/health/live` sans authentification, et
|
||||
le job restait vert. Un dump des octets échangés (`socat -v`, retiré depuis) a montré la cause : ZAP
|
||||
ajoutait à chaque requête une ligne d'en-tête au **nom vide**, `: Bearer <jeton>`. La clé de
|
||||
configuration du nom d'en-tête pour la règle Replacer est **`matchstr`** ; le job écrivait
|
||||
`matchstring` (nom utilisé par le job d'automatisation ZAP, pas par `-config`). ZAP accepte
|
||||
n'importe quelle clé `-config` sans erreur, il a donc laissé le nom vide. Le job porte deux garde-fous
|
||||
qui font échouer un scan qui n'a rien testé : moins de 10 URL importées, ou 100 % de réponses 4xx.
|
||||
- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26
|
||||
opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP
|
||||
refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en
|
||||
ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette
|
||||
étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus
|
||||
permissive qu'elle ne l'était.
|
||||
- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration
|
||||
du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que
|
||||
pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans
|
||||
erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête,
|
||||
y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner
|
||||
le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre
|
||||
d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec
|
||||
seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un
|
||||
scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul,
|
||||
aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent
|
||||
`zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport
|
||||
Markdown.
|
||||
|
||||
Piège de permissions : le dossier `zap-out` appartient à l'uid 1000 du conteneur, le runner n'y écrit
|
||||
plus après le `chown` ; les journaux vont donc dans `zap-logs/`, que le runner possède.
|
||||
Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans
|
||||
l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid
|
||||
1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour
|
||||
diagnostiquer un futur import raté.
|
||||
|
||||
**Non bloquant pour l'instant** (`continue-on-error`) pour ce qui est des alertes. 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.
|
||||
**Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est
|
||||
des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport
|
||||
HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux
|
||||
d'alertes, sans le détail par alerte) 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
|
||||
|
||||
Reference in New Issue
Block a user