PoC BunkerWeb 1.6.17 : docker-compose sur Proxmox, déployé par Ansible
Stack BunkerWeb (scheduler, UI, MariaDB, Redis), sites de test (nginx, Juice Shop, go-httpbin), filtrage DOCKER-USER, script de tests, README et schéma d'architecture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
099584c95c
42 files changed
+4260
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
credentials/
|
||||||
|
collections/
|
||||||
|
build/
|
||||||
|
*.retry
|
||||||
|
.vault_pass
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Raccourcis du PoC BunkerWeb
|
||||||
|
INV ?= inventory/hosts.yml
|
||||||
|
|
||||||
|
.PHONY: deps deploy vms base apps waf client tests destroy creds
|
||||||
|
|
||||||
|
deps: ## Collections Ansible
|
||||||
|
ansible-galaxy collection install -r requirements.yml -p ./collections
|
||||||
|
deploy: ## Déploiement complet
|
||||||
|
ansible-playbook -i $(INV) playbooks/site.yml
|
||||||
|
vms:
|
||||||
|
ansible-playbook -i $(INV) playbooks/00-proxmox.yml
|
||||||
|
base:
|
||||||
|
ansible-playbook -i $(INV) playbooks/10-base.yml
|
||||||
|
apps:
|
||||||
|
ansible-playbook -i $(INV) playbooks/20-apps.yml
|
||||||
|
waf:
|
||||||
|
ansible-playbook -i $(INV) playbooks/30-bunkerweb.yml
|
||||||
|
client:
|
||||||
|
ansible-playbook -i $(INV) playbooks/40-client.yml
|
||||||
|
tests:
|
||||||
|
ansible-playbook -i $(INV) playbooks/50-tests.yml
|
||||||
|
destroy:
|
||||||
|
ansible-playbook -i $(INV) playbooks/99-destroy.yml
|
||||||
|
creds: ## Affiche les accès à l'UI
|
||||||
|
@cat credentials/ACCES.txt
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# PoC BunkerWeb sur Proxmox
|
||||||
|
|
||||||
|
Un environnement de test complet pour [BunkerWeb](https://www.bunkerweb.io/), le reverse proxy WAF open source basé sur nginx, déployé en docker-compose sur des VM Proxmox, de bout en bout par Ansible.
|
||||||
|
|
||||||
|
L'objectif : voir concrètement ce que BunkerWeb bloque, laisse passer et journalise, sur des applis qui s'y prêtent, puis rejouer les mêmes attaques sans WAF pour comparer.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Ce que contient le PoC
|
||||||
|
|
||||||
|
| VM | Rôle | Conteneurs |
|
||||||
|
|---|---|---|
|
||||||
|
| `bw-waf` | Point d'entrée unique, ports 80/443 (TCP + UDP pour HTTP/3) | `bunkerweb`, `bw-scheduler`, `bw-ui`, `bw-db` (MariaDB 11), `bw-redis` |
|
||||||
|
| `bw-apps` | Sites de test, joignables **uniquement** depuis le WAF (iptables `DOCKER-USER`) | `poc-www` (nginx), `poc-shop` (OWASP Juice Shop), `poc-api` (go-httpbin) |
|
||||||
|
| `bw-client` | Poste "attaquant" avec les outils et le script de tests | `curl`, `sqlmap`, `nmap`, `ab`, `bwpoc-tests` |
|
||||||
|
|
||||||
|
Versions épinglées dans [`inventory/group_vars/all.yml`](inventory/group_vars/all.yml) : BunkerWeb **1.6.17**, Juice Shop v20.3.0, go-httpbin 2.26.0, nginx 1.30, Debian 13 (trixie) genericcloud.
|
||||||
|
|
||||||
|
Une variante **tout-en-un** (une seule VM) est fournie : [`inventory/single.yml`](inventory/single.yml). Le mode est détecté automatiquement : si WAF et applis sont le même hôte, BunkerWeb joint les applis par le réseau Docker `bw-services` et aucun port applicatif n'est publié.
|
||||||
|
|
||||||
|
### Les sites de test
|
||||||
|
|
||||||
|
Chaque site met en avant un groupe de fonctionnalités :
|
||||||
|
|
||||||
|
| Site | Appli | Ce qu'on y teste |
|
||||||
|
|---|---|---|
|
||||||
|
| `www.<domaine>` | vitrine nginx statique (page d'accueil + formulaire de contact) | antibot JavaScript, en-têtes de sécurité, redirection HTTPS, XSS via formulaire dans le navigateur |
|
||||||
|
| `shop.<domaine>` | [OWASP Juice Shop](https://owasp.org/www-project-juice-shop/), appli volontairement vulnérable | ModSecurity + OWASP CRS 4 (SQLi, XSS, traversée), websockets, User-Agents malveillants |
|
||||||
|
| `api.<domaine>` | [go-httpbin](https://github.com/mccutchen/go-httpbin), API qui renvoie ce qu'elle reçoit | limitation de débit, méthodes autorisées, taille max du corps, bad behavior et bannissement, règle ModSecurity maison, compression, `X-Forwarded-For` |
|
||||||
|
| `bwui.<domaine>` | interface web de BunkerWeb | rapports d'attaques, bannissements, réglages, journaux |
|
||||||
|
|
||||||
|
Le domaine par défaut est `bwpoc.internal` (TLD réservé par l'ICANN pour l'usage privé). Les certificats sont auto-signés par BunkerWeb, site par site.
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Un nœud Proxmox VE 8 joignable en SSH **root** depuis le poste Ansible, avec accès Internet (image Debian, images Docker, listes noires communautaires).
|
||||||
|
- 3 IP libres sur le réseau des VM (1 en mode tout-en-un). Le playbook vérifie par ping qu'elles sont libres avant de créer les VM.
|
||||||
|
- Sur le poste Ansible : `ansible-core` 2.16 ou plus, une paire de clés SSH.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@gitea.vlab.bzh:vLab-BZH/bunkerwebpoc.git && cd bunkerwebpoc
|
||||||
|
make deps # installe community.docker et ansible.posix dans ./collections
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Tout ce qui dépend de votre infra est marqué `A ADAPTER` :
|
||||||
|
|
||||||
|
1. [`inventory/hosts.yml`](inventory/hosts.yml) : IP du nœud Proxmox, IP et VMID des 3 VM, CPU, RAM, disque.
|
||||||
|
2. [`inventory/group_vars/all.yml`](inventory/group_vars/all.yml) : domaine, passerelle, DNS, stockage Proxmox, pont, VLAN, clé SSH publique.
|
||||||
|
3. [`inventory/group_vars/bunkerweb.yml`](inventory/group_vars/bunkerweb.yml) : les réglages BunkerWeb, globaux et par site. C'est le fichier à modifier pour jouer avec les fonctionnalités.
|
||||||
|
|
||||||
|
Les mots de passe (MariaDB, Redis, admin de l'UI) sont générés au premier lancement dans `credentials/` côté poste Ansible (ignoré par git). Pour les mettre sous `ansible-vault`, remplacez les `lookup('password')` de `bunkerweb.yml` par vos variables chiffrées.
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy # = ansible-playbook playbooks/site.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
| Étape | Playbook | Ce qu'elle fait |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `00-proxmox.yml` | télécharge l'image Debian 13 genericcloud, crée le modèle cloud-init (VMID 9100), clone les VM, injecte IP et clé SSH, agrandit les disques, démarre |
|
||||||
|
| 2 | `10-base.yml` | paquets de base, agent QEMU, résolution locale des noms du PoC vers le WAF, Docker CE depuis le dépôt officiel |
|
||||||
|
| 3 | `20-apps.yml` | stack des applis, filtrage `DOCKER-USER` (mode split) |
|
||||||
|
| 4 | `30-bunkerweb.yml` | stack BunkerWeb, attente qu'`api` réponde au travers du WAF, écriture de `credentials/ACCES.txt` |
|
||||||
|
| 5 | `40-client.yml` | outils d'attaque et script de tests sur `bw-client` |
|
||||||
|
|
||||||
|
Vous avez déjà vos VM (Debian 12 ou 13, accès SSH avec sudo) ? Renseignez-les dans l'inventaire et sautez l'étape 1 : `make base apps waf client`.
|
||||||
|
|
||||||
|
Pour relire les fichiers compose générés sans rien déployer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook playbooks/render.yml # écrit build/split/... ou build/single/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Un exemple de rendu (secrets remplacés par `CHANGEME`) est versionné dans [`examples/split/`](examples/split/) (`bunkerweb.compose.yml`, `apps.compose.yml`, `bwpoc-firewall.sh`).
|
||||||
|
|
||||||
|
### Accéder aux sites depuis votre poste
|
||||||
|
|
||||||
|
Les VM du PoC résolvent déjà les noms (bloc dans `/etc/hosts`). Sur votre poste, ajoutez la ligne suivante à `/etc/hosts`, ou déclarez les enregistrements dans votre DNS de labo :
|
||||||
|
|
||||||
|
```
|
||||||
|
192.168.200.61 www.bwpoc.internal shop.bwpoc.internal api.bwpoc.internal bwui.bwpoc.internal
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis ouvrez `https://bwui.bwpoc.internal/` (certificat auto-signé à accepter). Identifiants : `make creds`.
|
||||||
|
|
||||||
|
## Comment BunkerWeb est configuré ici
|
||||||
|
|
||||||
|
Intégration **Docker** officielle (pas l'autoconf) : toute la configuration est portée par les variables d'environnement du `bw-scheduler`, qui génère la configuration nginx et la pousse à l'instance `bunkerweb` par son API (réseau `bw-universe` 10.20.30.0/24, seul autorisé par `API_WHITELIST_IP`). Ce choix permet de protéger des backends situés sur une autre VM, ce que les labels de l'autoconf ne savent pas faire.
|
||||||
|
|
||||||
|
`MULTISITE=yes` : chaque réglage peut être préfixé par le nom du site (`api.bwpoc.internal_LIMIT_REQ_RATE=5r/s`). Dans Ansible, il suffit de remplir le dictionnaire `bw_sites` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
bw_sites:
|
||||||
|
api:
|
||||||
|
upstream: api # clé de apps_ports, ou URL complète
|
||||||
|
settings:
|
||||||
|
ALLOWED_METHODS: "GET|POST|HEAD"
|
||||||
|
MAX_CLIENT_SIZE: "1m"
|
||||||
|
LIMIT_REQ_RATE: "5r/s"
|
||||||
|
custom_confs: # devient api.<domaine>_CUSTOM_CONF_MODSEC_poc-rule
|
||||||
|
MODSEC_poc-rule: >-
|
||||||
|
SecRule ARGS:poc "@streq attack" "id:10001,phase:2,deny,status:403,log,msg:'PoC'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Les réglages posés par variables d'environnement apparaissent dans l'UI en lecture seule (méthode `scheduler`). Les modifications faites dans l'UI sont stockées en base et s'ajoutent par-dessus.
|
||||||
|
|
||||||
|
Redis est activé (`USE_REDIS=yes`) : bans, sessions et compteurs sont partagés, ce qui prépare l'ajout d'une seconde instance `bunkerweb` derrière un VIP (keepalived) sans changer le reste.
|
||||||
|
|
||||||
|
## Tester les fonctionnalités
|
||||||
|
|
||||||
|
Depuis `bw-client` (ou n'importe quel poste avec `curl` et `jq`, le script utilise `--resolve` et n'a pas besoin de DNS) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make tests # depuis le poste Ansible
|
||||||
|
ssh debian@192.168.200.63 bwpoc-tests # ou directement sur le client
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script enchaîne une vingtaine de contrôles et affiche `OK`, `KO` ou `INFO` :
|
||||||
|
|
||||||
|
| Fonctionnalité | Réglage | Requête | Attendu |
|
||||||
|
|---|---|---|---|
|
||||||
|
| HTTPS forcé | `AUTO_REDIRECT_HTTP_TO_HTTPS` | `http://api/get` | 301 vers https |
|
||||||
|
| Serveur par défaut fermé | `DISABLE_DEFAULT_SERVER` | Host inconnu | connexion fermée (444) |
|
||||||
|
| En-têtes de sécurité | `STRICT_TRANSPORT_SECURITY`, `CONTENT_SECURITY_POLICY`... | `api/get` | en-têtes présents, `Server` masqué |
|
||||||
|
| Proxy transparent | `USE_REVERSE_PROXY` | `api/headers` | le backend voit `X-Forwarded-For` |
|
||||||
|
| Compression | `USE_GZIP`, `USE_BROTLI` | `api/html` | `Content-Encoding: br` ou `gzip` |
|
||||||
|
| Antibot | `USE_ANTIBOT=javascript` | `www/` en curl | redirection vers `/challenge` |
|
||||||
|
| WAF SQLi | ModSecurity + CRS 4 | `shop/rest/products/search?q=')) OR 1=1--` | 403 |
|
||||||
|
| WAF XSS | idem | `shop/?q=<script>alert(1)</script>` | 403 |
|
||||||
|
| WAF traversée | idem | `shop/?file=../../../../etc/passwd` | 403 |
|
||||||
|
| WAF injection de commande | idem | `api/get?cmd=;cat /etc/passwd` | 403 |
|
||||||
|
| Règle maison | `CUSTOM_CONF_MODSEC` | `api/get?poc=attack` | 403 |
|
||||||
|
| User-Agent maison | `BLACKLIST_USER_AGENT` | `-A PoC-EvilBot` | 403 |
|
||||||
|
| User-Agent communautaire | `BLACKLIST_COMMUNITY_LISTS` | `-A sqlmap/1.8` | 403 (info : dépend du téléchargement de la liste) |
|
||||||
|
| Méthodes | `ALLOWED_METHODS` | `PUT api/put` | 405 |
|
||||||
|
| Taille du corps | `MAX_CLIENT_SIZE=1m` | POST de 2 Mo | 413 |
|
||||||
|
| Limitation de débit | `LIMIT_REQ_RATE=5r/s` | 40 requêtes en parallèle | une partie en 429 |
|
||||||
|
| Bad behavior | `BAD_BEHAVIOR_THRESHOLD=10` en 1 min | 12 × `/status/404` | IP bannie (403) sur `api` seulement |
|
||||||
|
|
||||||
|
Les derniers tests **bannissent volontairement votre IP** du site `api` pendant 5 minutes (`bw_ban_time`). Pour lever le ban : menu *Bans* de l'UI, ou sur `bw-waf` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec bw-scheduler bwcli unban 192.168.200.63
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avec et sans WAF
|
||||||
|
|
||||||
|
Pour mesurer l'apport du WAF, autorisez le client à joindre les applis en direct, puis rejouez les attaques :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# inventory/group_vars/all.yml
|
||||||
|
apps_direct_access_sources: [192.168.200.63]
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make apps
|
||||||
|
ssh debian@192.168.200.63 bwpoc-tests --direct
|
||||||
|
```
|
||||||
|
|
||||||
|
La SQLi de Juice Shop renvoie alors la liste complète des produits (200) au lieu d'un 403, et le `PUT` passe.
|
||||||
|
|
||||||
|
### Aller plus loin à la main
|
||||||
|
|
||||||
|
- **sqlmap** contre Juice Shop, d'abord avec son User-Agent par défaut (bloqué par la liste communautaire), puis avec `--random-agent` pour voir le CRS prendre le relais :
|
||||||
|
`sqlmap -u "https://shop.bwpoc.internal/rest/products/search?q=test" --random-agent --batch`
|
||||||
|
- **Antibot** dans le navigateur : passez `www` en `captcha` ou `cookie` dans `bunkerweb.yml`, relancez `make waf`.
|
||||||
|
- **Faux positifs** : naviguez dans Juice Shop (connexion, panier, avis). Ce que le CRS bloque à tort apparaît dans l'UI (*Reports*) avec l'ID de règle ; on l'exclut par un `custom_confs` de type `MODSEC_CRS` (exemple : `SecRuleRemoveById 942100`).
|
||||||
|
- **Mode détection seule** : `MODSECURITY_SEC_RULE_ENGINE: "DetectionOnly"` sur un site, pour observer avant de bloquer, comme on le ferait en production.
|
||||||
|
- **Journaux** : `docker logs -f bunkerweb` sur `bw-waf`, et l'audit ModSecurity dans `/var/log/bunkerweb/modsec_audit.log` du conteneur.
|
||||||
|
|
||||||
|
## Exploitation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make waf # réapplique la conf BunkerWeb après modification de bunkerweb.yml
|
||||||
|
make destroy # supprime les VM (confirmation demandée), modèle conservé
|
||||||
|
ansible-playbook playbooks/99-destroy.yml -e destroy_template=true # supprime aussi le modèle
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur `bw-waf`, la stack vit dans `/opt/bwpoc/bunkerweb` (`docker compose ps`, `logs`, `pull`...). Sur `bw-apps`, dans `/opt/bwpoc/apps`.
|
||||||
|
|
||||||
|
## Limites assumées du PoC
|
||||||
|
|
||||||
|
- Certificats auto-signés : pour du Let's Encrypt en labo fermé, passer par le challenge DNS (`AUTO_LETS_ENCRYPT`, `LETS_ENCRYPT_CHALLENGE=dns`) avec votre fournisseur DNS.
|
||||||
|
- Une seule instance BunkerWeb : pas de haute disponibilité, Redis est là pour la suite.
|
||||||
|
- Les fonctions PRO de BunkerWeb (anti DDoS, monitoring Prometheus, cache, rapports) ne sont pas couvertes.
|
||||||
|
- Le géoblocage (`BLACKLIST_COUNTRY`) et les DNSBL ne se testent pas depuis des IP privées.
|
||||||
|
|
||||||
|
## Arborescence
|
||||||
|
|
||||||
|
```
|
||||||
|
inventory/ hosts.yml (3 VM), single.yml (tout-en-un), group_vars/
|
||||||
|
playbooks/ 00 à 50 + site.yml, render.yml, 99-destroy.yml
|
||||||
|
roles/
|
||||||
|
proxmox_template/ modèle Debian 13 cloud-init via qm
|
||||||
|
proxmox_vm/ clonage, cloud-init, démarrage
|
||||||
|
common/ paquets, agent QEMU, /etc/hosts
|
||||||
|
docker/ Docker CE + compose (dépôt officiel)
|
||||||
|
apps/ compose des applis, vitrine, filtrage DOCKER-USER
|
||||||
|
bunkerweb/ compose BunkerWeb (template Jinja)
|
||||||
|
client/ outils et script de tests
|
||||||
|
tests/bwpoc-tests.sh batterie de tests fonctionnels
|
||||||
|
examples/split/ compose générés, pour lecture
|
||||||
|
docs/ schéma d'architecture (.excalidraw et .png)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- Documentation BunkerWeb : <https://docs.bunkerweb.io/latest/>
|
||||||
|
- Fichiers d'intégration officiels (docker, MariaDB, UI) : <https://github.com/bunkerity/bunkerweb/tree/v1.6.17/misc/integrations>
|
||||||
|
- Liste des réglages, extraite des `plugin.json` : <https://github.com/bunkerity/bunkerweb/tree/v1.6.17/src/common/core>
|
||||||
|
- OWASP Core Rule Set : <https://coreruleset.org/>
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[defaults]
|
||||||
|
inventory = inventory/hosts.yml
|
||||||
|
roles_path = roles
|
||||||
|
collections_path = ./collections:~/.ansible/collections
|
||||||
|
host_key_checking = False
|
||||||
|
stdout_callback = default
|
||||||
|
result_format = yaml
|
||||||
|
retry_files_enabled = False
|
||||||
|
interpreter_python = auto_silent
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 718 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
# Ansible managed
|
||||||
|
# Sites de test du PoC BunkerWeb, mode split
|
||||||
|
# Ports publiés sur 192.168.200.62, filtrés par la chaîne iptables BWPOC :
|
||||||
|
# seul le WAF (192.168.200.61) peut les joindre.
|
||||||
|
|
||||||
|
services:
|
||||||
|
www:
|
||||||
|
image: nginx:1.30-alpine
|
||||||
|
container_name: poc-www
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /run
|
||||||
|
- /tmp
|
||||||
|
volumes:
|
||||||
|
- ./www:/usr/share/nginx/html:ro
|
||||||
|
ports:
|
||||||
|
- "192.168.200.62:8081:80"
|
||||||
|
networks: [apps]
|
||||||
|
|
||||||
|
shop:
|
||||||
|
image: bkimminich/juice-shop:v20.3.0
|
||||||
|
container_name: poc-shop
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "192.168.200.62:8082:3000"
|
||||||
|
networks: [apps]
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: mccutchen/go-httpbin:2.26.0
|
||||||
|
container_name: poc-api
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
ports:
|
||||||
|
- "192.168.200.62:8083:8080"
|
||||||
|
networks: [apps]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
apps:
|
||||||
|
name: poc-apps
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Ansible managed
|
||||||
|
# Stack BunkerWeb 1.6.17 : WAF + scheduler + UI + MariaDB + Redis
|
||||||
|
# Mode split, applis sur 192.168.200.62
|
||||||
|
x-db-env: &db-env
|
||||||
|
DATABASE_URI: "mariadb+pymysql://bunkerweb:CHANGEME@bw-db:3306/db"
|
||||||
|
|
||||||
|
x-api-env: &api-env
|
||||||
|
API_WHITELIST_IP: "127.0.0.0/8 10.20.30.0/24"
|
||||||
|
|
||||||
|
services:
|
||||||
|
bunkerweb:
|
||||||
|
image: bunkerity/bunkerweb:1.6.17
|
||||||
|
container_name: bunkerweb
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:8080/tcp"
|
||||||
|
- "443:8443/tcp"
|
||||||
|
- "443:8443/udp" # HTTP/3 (QUIC)
|
||||||
|
labels:
|
||||||
|
- "bunkerweb.INSTANCE=yes"
|
||||||
|
environment:
|
||||||
|
<<: [*db-env, *api-env]
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
- bw-services
|
||||||
|
|
||||||
|
bw-scheduler:
|
||||||
|
image: bunkerity/bunkerweb-scheduler:1.6.17
|
||||||
|
container_name: bw-scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- bunkerweb
|
||||||
|
- bw-db
|
||||||
|
- bw-redis
|
||||||
|
volumes:
|
||||||
|
- bw-storage:/data
|
||||||
|
environment:
|
||||||
|
<<: [*db-env, *api-env]
|
||||||
|
BUNKERWEB_INSTANCES: "bunkerweb"
|
||||||
|
SERVER_NAME: "www.bwpoc.internal shop.bwpoc.internal api.bwpoc.internal bwui.bwpoc.internal"
|
||||||
|
# --- Réglages globaux ---
|
||||||
|
MULTISITE: "yes"
|
||||||
|
DISABLE_DEFAULT_SERVER: "yes"
|
||||||
|
SERVE_FILES: "no"
|
||||||
|
GENERATE_SELF_SIGNED_SSL: "yes"
|
||||||
|
AUTO_REDIRECT_HTTP_TO_HTTPS: "yes"
|
||||||
|
USE_MODSECURITY: "yes"
|
||||||
|
USE_MODSECURITY_CRS: "yes"
|
||||||
|
MODSECURITY_CRS_VERSION: "4"
|
||||||
|
USE_BAD_BEHAVIOR: "yes"
|
||||||
|
BAD_BEHAVIOR_THRESHOLD: "10"
|
||||||
|
BAD_BEHAVIOR_COUNT_TIME: "1m"
|
||||||
|
BAD_BEHAVIOR_BAN_TIME: "5m"
|
||||||
|
USE_BLACKLIST: "yes"
|
||||||
|
BLACKLIST_USER_AGENT: "PoC-EvilBot"
|
||||||
|
USE_GZIP: "yes"
|
||||||
|
USE_BROTLI: "yes"
|
||||||
|
USE_REDIS: "yes"
|
||||||
|
REDIS_HOST: "bw-redis"
|
||||||
|
REDIS_PASSWORD: "CHANGEME"
|
||||||
|
SEND_ANONYMOUS_REPORT: "no"
|
||||||
|
# --- www.bwpoc.internal ---
|
||||||
|
www.bwpoc.internal_USE_REVERSE_PROXY: "yes"
|
||||||
|
www.bwpoc.internal_REVERSE_PROXY_URL: "/"
|
||||||
|
www.bwpoc.internal_REVERSE_PROXY_HOST: "http://192.168.200.62:8081"
|
||||||
|
www.bwpoc.internal_SELF_SIGNED_SSL_SUBJ: "/CN=www.bwpoc.internal/"
|
||||||
|
www.bwpoc.internal_USE_ANTIBOT: "javascript"
|
||||||
|
www.bwpoc.internal_LIMIT_REQ_RATE: "10r/s"
|
||||||
|
# --- shop.bwpoc.internal ---
|
||||||
|
shop.bwpoc.internal_USE_REVERSE_PROXY: "yes"
|
||||||
|
shop.bwpoc.internal_REVERSE_PROXY_URL: "/"
|
||||||
|
shop.bwpoc.internal_REVERSE_PROXY_HOST: "http://192.168.200.62:8082"
|
||||||
|
shop.bwpoc.internal_SELF_SIGNED_SSL_SUBJ: "/CN=shop.bwpoc.internal/"
|
||||||
|
shop.bwpoc.internal_REVERSE_PROXY_WS: "yes"
|
||||||
|
shop.bwpoc.internal_LIMIT_REQ_RATE: "30r/s"
|
||||||
|
shop.bwpoc.internal_COOKIE_FLAGS: "* SameSite=Lax"
|
||||||
|
# --- api.bwpoc.internal ---
|
||||||
|
api.bwpoc.internal_USE_REVERSE_PROXY: "yes"
|
||||||
|
api.bwpoc.internal_REVERSE_PROXY_URL: "/"
|
||||||
|
api.bwpoc.internal_REVERSE_PROXY_HOST: "http://192.168.200.62:8083"
|
||||||
|
api.bwpoc.internal_SELF_SIGNED_SSL_SUBJ: "/CN=api.bwpoc.internal/"
|
||||||
|
api.bwpoc.internal_ALLOWED_METHODS: "GET|POST|HEAD"
|
||||||
|
api.bwpoc.internal_MAX_CLIENT_SIZE: "1m"
|
||||||
|
api.bwpoc.internal_LIMIT_REQ_RATE: "5r/s"
|
||||||
|
api.bwpoc.internal_CUSTOM_CONF_MODSEC_poc-rule: "SecRule ARGS:poc \"@streq attack\" \"id:10001,phase:2,deny,status:403,log,msg:'PoC BunkerWeb : regle maison'\""
|
||||||
|
# --- bwui.bwpoc.internal ---
|
||||||
|
bwui.bwpoc.internal_USE_REVERSE_PROXY: "yes"
|
||||||
|
bwui.bwpoc.internal_REVERSE_PROXY_URL: "/"
|
||||||
|
bwui.bwpoc.internal_REVERSE_PROXY_HOST: "http://bw-ui:7000"
|
||||||
|
bwui.bwpoc.internal_SELF_SIGNED_SSL_SUBJ: "/CN=bwui.bwpoc.internal/"
|
||||||
|
bwui.bwpoc.internal_USE_TEMPLATE: "ui"
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
- bw-db
|
||||||
|
|
||||||
|
bw-ui:
|
||||||
|
image: bunkerity/bunkerweb-ui:1.6.17
|
||||||
|
container_name: bw-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- bw-db
|
||||||
|
volumes:
|
||||||
|
- bw-ui-data:/data # secrets Flask, clés TOTP et Biscuit
|
||||||
|
environment:
|
||||||
|
<<: *db-env
|
||||||
|
ADMIN_USERNAME: "admin"
|
||||||
|
ADMIN_PASSWORD: "CHANGEME"
|
||||||
|
TZ: "Europe/Paris"
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
- bw-db
|
||||||
|
|
||||||
|
bw-db:
|
||||||
|
image: mariadb:11
|
||||||
|
container_name: bw-db
|
||||||
|
restart: unless-stopped
|
||||||
|
command: --max-allowed-packet=67108864
|
||||||
|
environment:
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||||
|
MYSQL_DATABASE: "db"
|
||||||
|
MYSQL_USER: "bunkerweb"
|
||||||
|
MYSQL_PASSWORD: "CHANGEME"
|
||||||
|
volumes:
|
||||||
|
- bw-data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- bw-db
|
||||||
|
|
||||||
|
bw-redis:
|
||||||
|
image: redis:8-alpine
|
||||||
|
container_name: bw-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--requirepass", "CHANGEME", "--save", "60", "1"]
|
||||||
|
volumes:
|
||||||
|
- bw-redis:/data
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bw-data:
|
||||||
|
bw-storage:
|
||||||
|
bw-ui-data:
|
||||||
|
bw-redis:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bw-universe:
|
||||||
|
name: bw-universe
|
||||||
|
ipam:
|
||||||
|
driver: default
|
||||||
|
config:
|
||||||
|
- subnet: 10.20.30.0/24 # seul réseau autorisé à parler à l'API BunkerWeb
|
||||||
|
bw-services:
|
||||||
|
name: bw-services # réseau des applis (mode single), créé par Ansible
|
||||||
|
external: true
|
||||||
|
bw-db:
|
||||||
|
name: bw-db
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Ansible managed
|
||||||
|
# N'autorise que le WAF (et les sources listées) à joindre les ports publiés
|
||||||
|
# des applis. Les règles vont dans DOCKER-USER, consultée par Docker avant
|
||||||
|
# ses propres règles ; --ctorigdstport filtre sur le port publié (avant DNAT).
|
||||||
|
set -eu
|
||||||
|
IFACE="ens18"
|
||||||
|
PORTS="8081:8083"
|
||||||
|
|
||||||
|
iptables -N BWPOC 2>/dev/null || iptables -F BWPOC
|
||||||
|
iptables -A BWPOC -s 192.168.200.61 -j RETURN
|
||||||
|
iptables -A BWPOC -j DROP
|
||||||
|
|
||||||
|
RULE="-i $IFACE -p tcp -m conntrack --ctstate NEW --ctorigdstport $PORTS -j BWPOC"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
iptables -C DOCKER-USER $RULE 2>/dev/null || iptables -I DOCKER-USER $RULE
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
# =====================================================================
|
||||||
|
# Variables communes du PoC BunkerWeb
|
||||||
|
# Les valeurs marquées "A ADAPTER" dépendent de votre infra.
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# --- Domaine de test -------------------------------------------------
|
||||||
|
# Les sites seront www.<domaine>, shop.<domaine>, api.<domaine>, bwui.<domaine>
|
||||||
|
# ".internal" est réservé par l'ICANN pour l'usage privé : pas de collision possible.
|
||||||
|
poc_domain: bwpoc.internal # A ADAPTER si vous avez une zone DNS de labo
|
||||||
|
|
||||||
|
# --- Réseau des VM (cloud-init) --------------------------------------
|
||||||
|
poc_net_prefix: 24 # A ADAPTER
|
||||||
|
poc_net_gateway: 192.168.200.1 # A ADAPTER
|
||||||
|
poc_net_dns: [192.168.200.1] # A ADAPTER
|
||||||
|
poc_net_searchdomain: "{{ poc_domain }}"
|
||||||
|
|
||||||
|
# --- Proxmox ----------------------------------------------------------
|
||||||
|
pve_storage: local-lvm # A ADAPTER : stockage des disques VM
|
||||||
|
pve_bridge: vmbr0 # A ADAPTER
|
||||||
|
pve_vlan_tag: "" # vide = pas de VLAN, sinon ex. "200"
|
||||||
|
pve_template_vmid: 9100
|
||||||
|
pve_template_name: debian13-cloud
|
||||||
|
pve_image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
|
||||||
|
pve_image_path: /var/lib/vz/template/debian-13-genericcloud-amd64.qcow2
|
||||||
|
|
||||||
|
# --- Accès aux VM -----------------------------------------------------
|
||||||
|
poc_vm_user: debian
|
||||||
|
poc_ssh_pubkey_file: "~/.ssh/id_ed25519.pub" # clé injectée par cloud-init
|
||||||
|
|
||||||
|
# --- Versions (épinglées) --------------------------------------------
|
||||||
|
bunkerweb_version: "1.6.17"
|
||||||
|
mariadb_image: "mariadb:11"
|
||||||
|
redis_image: "redis:8-alpine"
|
||||||
|
juiceshop_image: "bkimminich/juice-shop:v20.3.0"
|
||||||
|
httpbin_image: "mccutchen/go-httpbin:2.26.0"
|
||||||
|
nginx_image: "nginx:1.30-alpine"
|
||||||
|
|
||||||
|
# --- Arborescence sur les VM -----------------------------------------
|
||||||
|
poc_base_dir: /opt/bwpoc
|
||||||
|
|
||||||
|
# --- Ports publiés par la VM applis (mode split) ---------------------
|
||||||
|
apps_ports:
|
||||||
|
www: 8081
|
||||||
|
shop: 8082
|
||||||
|
api: 8083
|
||||||
|
|
||||||
|
# Ports d'écoute dans les conteneurs (utilisés en mode single)
|
||||||
|
apps_internal_ports:
|
||||||
|
www: 80
|
||||||
|
shop: 3000
|
||||||
|
api: 8080
|
||||||
|
|
||||||
|
# Sources autorisées à joindre directement les applis, en plus du WAF.
|
||||||
|
# Ajoutez l'IP du client pour comparer "avec / sans WAF" (voir README).
|
||||||
|
apps_direct_access_sources: []
|
||||||
|
|
||||||
|
# Mode calculé : "single" si WAF et applis sont sur le même hôte.
|
||||||
|
poc_mode: "{{ 'single' if (groups['apps'] | first) == (groups['bunkerweb'] | first) else 'split' }}"
|
||||||
|
waf_ip: "{{ hostvars[groups['bunkerweb'] | first]['ansible_host'] }}"
|
||||||
|
apps_ip: "{{ hostvars[groups['apps'] | first]['ansible_host'] }}"
|
||||||
|
client_ip: "{{ hostvars[groups['client'] | first]['ansible_host'] }}"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
# =====================================================================
|
||||||
|
# Réglages BunkerWeb du PoC
|
||||||
|
# Chaque site met en avant un groupe de fonctionnalités (voir README).
|
||||||
|
# Noms de paramètres vérifiés sur les plugin.json de BunkerWeb 1.6.17.
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# Secrets générés au premier lancement et stockés côté contrôleur
|
||||||
|
# dans ./credentials (ignoré par git). Remplaçables par ansible-vault.
|
||||||
|
bw_db_password: "{{ lookup('ansible.builtin.password', playbook_dir ~ '/../credentials/bw_db_password', chars=['ascii_letters', 'digits'], length=24) }}"
|
||||||
|
bw_ui_admin_user: admin
|
||||||
|
# La politique de l'UI impose majuscule, minuscule, chiffre et caractère spécial.
|
||||||
|
bw_ui_admin_password: "{{ lookup('ansible.builtin.password', playbook_dir ~ '/../credentials/bw_ui_admin_password', chars=['ascii_letters', 'digits'], length=20) }}!Aa9"
|
||||||
|
bw_redis_password: "{{ lookup('ansible.builtin.password', playbook_dir ~ '/../credentials/bw_redis_password', chars=['ascii_letters', 'digits'], length=24) }}"
|
||||||
|
|
||||||
|
# Bannissement court pour pouvoir rejouer les tests (défaut BunkerWeb : 1d)
|
||||||
|
bw_ban_time: 5m
|
||||||
|
|
||||||
|
# Réglages globaux (contexte global ou valeurs par défaut de tous les sites)
|
||||||
|
bw_global_settings:
|
||||||
|
MULTISITE: "yes"
|
||||||
|
DISABLE_DEFAULT_SERVER: "yes" # Host inconnu = connexion fermée (444)
|
||||||
|
SERVE_FILES: "no"
|
||||||
|
# TLS : certificats auto-signés par site (pas d'ACME en labo fermé)
|
||||||
|
GENERATE_SELF_SIGNED_SSL: "yes"
|
||||||
|
AUTO_REDIRECT_HTTP_TO_HTTPS: "yes"
|
||||||
|
# WAF : ModSecurity + OWASP CRS v4 actifs partout (défaut BunkerWeb)
|
||||||
|
USE_MODSECURITY: "yes"
|
||||||
|
USE_MODSECURITY_CRS: "yes"
|
||||||
|
MODSECURITY_CRS_VERSION: "4"
|
||||||
|
# Bad behavior : 10 codes 4xx en 1 min = bannissement
|
||||||
|
USE_BAD_BEHAVIOR: "yes"
|
||||||
|
BAD_BEHAVIOR_THRESHOLD: "10"
|
||||||
|
BAD_BEHAVIOR_COUNT_TIME: "1m"
|
||||||
|
BAD_BEHAVIOR_BAN_TIME: "{{ bw_ban_time }}"
|
||||||
|
# Listes noires : listes communautaires + un User-Agent maison, testable à coup sûr
|
||||||
|
USE_BLACKLIST: "yes"
|
||||||
|
BLACKLIST_USER_AGENT: "PoC-EvilBot"
|
||||||
|
# Compression
|
||||||
|
USE_GZIP: "yes"
|
||||||
|
USE_BROTLI: "yes"
|
||||||
|
# Redis : bans et sessions partagés (prêt pour ajouter une 2e instance)
|
||||||
|
USE_REDIS: "yes"
|
||||||
|
REDIS_HOST: "bw-redis"
|
||||||
|
REDIS_PASSWORD: "{{ bw_redis_password }}"
|
||||||
|
SEND_ANONYMOUS_REPORT: "no"
|
||||||
|
|
||||||
|
# Un dictionnaire par site. "upstream" = clé de apps_ports (ou URL complète).
|
||||||
|
bw_sites:
|
||||||
|
# Vitrine statique : antibot (challenge JavaScript), en-têtes de sécurité, compression
|
||||||
|
www:
|
||||||
|
upstream: www
|
||||||
|
settings:
|
||||||
|
USE_ANTIBOT: "javascript"
|
||||||
|
LIMIT_REQ_RATE: "10r/s"
|
||||||
|
# OWASP Juice Shop : appli volontairement vulnérable, terrain de jeu du WAF CRS
|
||||||
|
shop:
|
||||||
|
upstream: shop
|
||||||
|
settings:
|
||||||
|
REVERSE_PROXY_WS: "yes" # socket.io
|
||||||
|
LIMIT_REQ_RATE: "30r/s" # SPA Angular, beaucoup d'assets
|
||||||
|
COOKIE_FLAGS: "* SameSite=Lax" # l'appli lit certains cookies en JS
|
||||||
|
# go-httpbin : API de test pour méthodes, tailles, limites, bad behavior, règle maison
|
||||||
|
api:
|
||||||
|
upstream: api
|
||||||
|
settings:
|
||||||
|
ALLOWED_METHODS: "GET|POST|HEAD"
|
||||||
|
MAX_CLIENT_SIZE: "1m"
|
||||||
|
LIMIT_REQ_RATE: "5r/s"
|
||||||
|
# Règle ModSecurity maison : ?poc=attack => 403
|
||||||
|
custom_confs:
|
||||||
|
MODSEC_poc-rule: >-
|
||||||
|
SecRule ARGS:poc "@streq attack"
|
||||||
|
"id:10001,phase:2,deny,status:403,log,msg:'PoC BunkerWeb : regle maison'"
|
||||||
|
# Interface d'administration de BunkerWeb
|
||||||
|
bwui:
|
||||||
|
upstream: "http://bw-ui:7000"
|
||||||
|
settings:
|
||||||
|
USE_TEMPLATE: "ui"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
# Inventaire du PoC BunkerWeb.
|
||||||
|
# Mode "split" (défaut) : 3 VM distinctes (WAF, applis, client).
|
||||||
|
# Mode "single" : voir inventory/single.yml (tout sur une seule VM).
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
proxmox:
|
||||||
|
hosts:
|
||||||
|
pve01:
|
||||||
|
ansible_host: 192.168.200.10 # A ADAPTER : nœud Proxmox qui héberge les VM
|
||||||
|
ansible_user: root
|
||||||
|
poc_vms:
|
||||||
|
vars:
|
||||||
|
ansible_user: debian
|
||||||
|
ansible_become: true
|
||||||
|
children:
|
||||||
|
bunkerweb:
|
||||||
|
hosts:
|
||||||
|
bw-waf:
|
||||||
|
ansible_host: 192.168.200.61 # A ADAPTER
|
||||||
|
vm_id: 9101
|
||||||
|
vm_cores: 2
|
||||||
|
vm_memory: 4096
|
||||||
|
vm_disk_gb: 20
|
||||||
|
apps:
|
||||||
|
hosts:
|
||||||
|
bw-apps:
|
||||||
|
ansible_host: 192.168.200.62 # A ADAPTER
|
||||||
|
vm_id: 9102
|
||||||
|
vm_cores: 2
|
||||||
|
vm_memory: 4096
|
||||||
|
vm_disk_gb: 20
|
||||||
|
client:
|
||||||
|
hosts:
|
||||||
|
bw-client:
|
||||||
|
ansible_host: 192.168.200.63 # A ADAPTER
|
||||||
|
vm_id: 9103
|
||||||
|
vm_cores: 1
|
||||||
|
vm_memory: 1024
|
||||||
|
vm_disk_gb: 10
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
# Variante "tout-en-un" : WAF, applis et outils de test sur la même VM.
|
||||||
|
# ansible-playbook -i inventory/single.yml playbooks/site.yml
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
proxmox:
|
||||||
|
hosts:
|
||||||
|
pve01:
|
||||||
|
ansible_host: 192.168.200.10 # A ADAPTER
|
||||||
|
ansible_user: root
|
||||||
|
poc_vms:
|
||||||
|
vars:
|
||||||
|
ansible_user: debian
|
||||||
|
ansible_become: true
|
||||||
|
children:
|
||||||
|
bunkerweb:
|
||||||
|
hosts:
|
||||||
|
bw-poc:
|
||||||
|
ansible_host: 192.168.200.61 # A ADAPTER
|
||||||
|
vm_id: 9101
|
||||||
|
vm_cores: 4
|
||||||
|
vm_memory: 6144
|
||||||
|
vm_disk_gb: 30
|
||||||
|
apps:
|
||||||
|
hosts:
|
||||||
|
bw-poc:
|
||||||
|
client:
|
||||||
|
hosts:
|
||||||
|
bw-poc:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
# Crée le modèle Debian 13 cloud-init puis clone les VM du PoC.
|
||||||
|
# A sauter si vous fournissez vos propres VM (Debian 12/13 avec accès SSH).
|
||||||
|
- name: Provisionner les VM sur Proxmox
|
||||||
|
hosts: proxmox
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- proxmox_template
|
||||||
|
- proxmox_vm
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Socle des VM (paquets, résolution locale, Docker)
|
||||||
|
hosts: poc_vms
|
||||||
|
roles:
|
||||||
|
- common
|
||||||
|
- docker
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Déployer les sites de test
|
||||||
|
hosts: apps
|
||||||
|
roles:
|
||||||
|
- apps
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Déployer BunkerWeb
|
||||||
|
hosts: bunkerweb
|
||||||
|
roles:
|
||||||
|
- bunkerweb
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Préparer le client de test
|
||||||
|
hosts: client
|
||||||
|
roles:
|
||||||
|
- client
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
# Lance la batterie de tests depuis le client et affiche le résultat.
|
||||||
|
- name: Tests fonctionnels BunkerWeb
|
||||||
|
hosts: client
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Exécuter bwpoc-tests
|
||||||
|
ansible.builtin.command: "{{ poc_base_dir }}/tests/bwpoc-tests.sh"
|
||||||
|
register: tests
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Résultat
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ tests.stdout_lines }}"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
# Supprime les VM du PoC (le modèle est conservé, sauf -e destroy_template=true).
|
||||||
|
- name: Détruire les VM du PoC
|
||||||
|
hosts: proxmox
|
||||||
|
gather_facts: false
|
||||||
|
vars_prompt:
|
||||||
|
- name: confirm
|
||||||
|
prompt: "Taper 'detruire' pour supprimer les VM {{ groups['poc_vms'] | unique | join(', ') }}"
|
||||||
|
private: false
|
||||||
|
tasks:
|
||||||
|
- name: Abandon sans confirmation
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: confirm != 'detruire'
|
||||||
|
|
||||||
|
- name: Arrêter et supprimer les VM
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
if qm status {{ hostvars[item].vm_id }} >/dev/null 2>&1; then
|
||||||
|
qm stop {{ hostvars[item].vm_id }} --skiplock 1 || true
|
||||||
|
qm destroy {{ hostvars[item].vm_id }} --purge 1 --destroy-unreferenced-disks 1
|
||||||
|
echo destroyed
|
||||||
|
fi
|
||||||
|
register: destroy
|
||||||
|
changed_when: "'destroyed' in destroy.stdout"
|
||||||
|
loop: "{{ groups['poc_vms'] | unique }}"
|
||||||
|
|
||||||
|
- name: Supprimer le modèle
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
if qm status {{ pve_template_vmid }} >/dev/null 2>&1; then
|
||||||
|
qm destroy {{ pve_template_vmid }} --purge 1 && echo destroyed
|
||||||
|
fi
|
||||||
|
register: tpl_destroy
|
||||||
|
changed_when: "'destroyed' in tpl_destroy.stdout"
|
||||||
|
when: destroy_template | default(false) | bool
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
# Génère les fichiers compose dans build/<mode>/ sans rien déployer,
|
||||||
|
# pour les relire ou les utiliser à la main : ansible-playbook playbooks/render.yml
|
||||||
|
- name: Rendu local des fichiers compose
|
||||||
|
hosts: bunkerweb:apps
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
render_dir: "{{ playbook_dir }}/../build/{{ poc_mode }}"
|
||||||
|
ansible_default_ipv4: { interface: ens18 }
|
||||||
|
tasks:
|
||||||
|
- name: Répertoires de sortie
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ render_dir }}/{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
loop: [bunkerweb, apps]
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Compose BunkerWeb
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: ../roles/bunkerweb/templates/compose.yml.j2
|
||||||
|
dest: "{{ render_dir }}/bunkerweb/compose.yml"
|
||||||
|
mode: "0600"
|
||||||
|
when: inventory_hostname in groups['bunkerweb']
|
||||||
|
|
||||||
|
- name: Compose applis et filtrage
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "../roles/apps/templates/{{ item.src }}"
|
||||||
|
dest: "{{ render_dir }}/apps/{{ item.dest }}"
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- { src: compose.yml.j2, dest: compose.yml }
|
||||||
|
- { src: bwpoc-firewall.sh.j2, dest: bwpoc-firewall.sh }
|
||||||
|
when: inventory_hostname in groups['apps']
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
# Déploiement complet du PoC : ansible-playbook playbooks/site.yml
|
||||||
|
- name: Proxmox
|
||||||
|
ansible.builtin.import_playbook: 00-proxmox.yml
|
||||||
|
- name: Socle
|
||||||
|
ansible.builtin.import_playbook: 10-base.yml
|
||||||
|
- name: Applis
|
||||||
|
ansible.builtin.import_playbook: 20-apps.yml
|
||||||
|
- name: BunkerWeb
|
||||||
|
ansible.builtin.import_playbook: 30-bunkerweb.yml
|
||||||
|
- name: Client
|
||||||
|
ansible.builtin.import_playbook: 40-client.yml
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
collections:
|
||||||
|
- name: community.docker
|
||||||
|
version: ">=3.10.0"
|
||||||
|
- name: ansible.posix
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
apps_dir: "{{ poc_base_dir }}/apps"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PoC BunkerWeb - filtrage des ports applis (DOCKER-USER)
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
PartOf=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/local/sbin/bwpoc-firewall.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target docker.service
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Recharger le filtrage applis
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: bwpoc-firewall.service
|
||||||
|
state: restarted
|
||||||
|
daemon_reload: true
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
- name: Créer l'arborescence des applis
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ apps_dir }}"
|
||||||
|
- "{{ apps_dir }}/www"
|
||||||
|
|
||||||
|
- name: Déposer la vitrine statique
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "www/{{ item }}.j2"
|
||||||
|
dest: "{{ apps_dir }}/www/{{ item }}"
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- index.html
|
||||||
|
- contact.html
|
||||||
|
|
||||||
|
- name: Créer le réseau partagé bw-services (mode single)
|
||||||
|
ansible.builtin.shell: docker network inspect bw-services >/dev/null 2>&1 || { docker network create bw-services && echo created; }
|
||||||
|
register: apps_net
|
||||||
|
changed_when: "'created' in apps_net.stdout"
|
||||||
|
when: poc_mode == 'single'
|
||||||
|
|
||||||
|
- name: Générer le compose des applis
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: compose.yml.j2
|
||||||
|
dest: "{{ apps_dir }}/compose.yml"
|
||||||
|
mode: "0644"
|
||||||
|
validate: docker compose -f %s config -q
|
||||||
|
|
||||||
|
- name: Démarrer les applis
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ apps_dir }}"
|
||||||
|
project_name: apps
|
||||||
|
pull: missing
|
||||||
|
remove_orphans: true
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Filtrage des ports publiés (mode split)
|
||||||
|
when: poc_mode == 'split'
|
||||||
|
block:
|
||||||
|
- name: Installer iptables
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: iptables
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Déposer le script de filtrage
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: bwpoc-firewall.sh.j2
|
||||||
|
dest: /usr/local/sbin/bwpoc-firewall.sh
|
||||||
|
mode: "0755"
|
||||||
|
notify: Recharger le filtrage applis
|
||||||
|
|
||||||
|
- name: Déposer l'unité systemd
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: bwpoc-firewall.service
|
||||||
|
dest: /etc/systemd/system/bwpoc-firewall.service
|
||||||
|
mode: "0644"
|
||||||
|
notify: Recharger le filtrage applis
|
||||||
|
|
||||||
|
- name: Activer le filtrage au démarrage
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: bwpoc-firewall.service
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
daemon_reload: true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
# N'autorise que le WAF (et les sources listées) à joindre les ports publiés
|
||||||
|
# des applis. Les règles vont dans DOCKER-USER, consultée par Docker avant
|
||||||
|
# ses propres règles ; --ctorigdstport filtre sur le port publié (avant DNAT).
|
||||||
|
set -eu
|
||||||
|
IFACE="{{ ansible_default_ipv4.interface }}"
|
||||||
|
PORTS="{{ apps_ports.values() | min }}:{{ apps_ports.values() | max }}"
|
||||||
|
|
||||||
|
iptables -N BWPOC 2>/dev/null || iptables -F BWPOC
|
||||||
|
{% for src in [waf_ip] + apps_direct_access_sources %}
|
||||||
|
iptables -A BWPOC -s {{ src }} -j RETURN
|
||||||
|
{% endfor %}
|
||||||
|
iptables -A BWPOC -j DROP
|
||||||
|
|
||||||
|
RULE="-i $IFACE -p tcp -m conntrack --ctstate NEW --ctorigdstport $PORTS -j BWPOC"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
iptables -C DOCKER-USER $RULE 2>/dev/null || iptables -I DOCKER-USER $RULE
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# {{ ansible_managed }}
|
||||||
|
# Sites de test du PoC BunkerWeb, mode {{ poc_mode }}
|
||||||
|
{% if poc_mode == 'split' %}
|
||||||
|
# Ports publiés sur {{ ansible_host }}, filtrés par la chaîne iptables BWPOC :
|
||||||
|
# seul le WAF ({{ waf_ip }}) peut les joindre.
|
||||||
|
{% else %}
|
||||||
|
# Aucun port publié : BunkerWeb joint les conteneurs par le réseau bw-services.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
services:
|
||||||
|
www:
|
||||||
|
image: {{ nginx_image }}
|
||||||
|
container_name: poc-www
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /run
|
||||||
|
- /tmp
|
||||||
|
volumes:
|
||||||
|
- ./www:/usr/share/nginx/html:ro
|
||||||
|
{% if poc_mode == 'split' %}
|
||||||
|
ports:
|
||||||
|
- "{{ ansible_host }}:{{ apps_ports.www }}:80"
|
||||||
|
{% endif %}
|
||||||
|
networks: [apps]
|
||||||
|
|
||||||
|
shop:
|
||||||
|
image: {{ juiceshop_image }}
|
||||||
|
container_name: poc-shop
|
||||||
|
restart: unless-stopped
|
||||||
|
{% if poc_mode == 'split' %}
|
||||||
|
ports:
|
||||||
|
- "{{ ansible_host }}:{{ apps_ports.shop }}:3000"
|
||||||
|
{% endif %}
|
||||||
|
networks: [apps]
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: {{ httpbin_image }}
|
||||||
|
container_name: poc-api
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
{% if poc_mode == 'split' %}
|
||||||
|
ports:
|
||||||
|
- "{{ ansible_host }}:{{ apps_ports.api }}:8080"
|
||||||
|
{% endif %}
|
||||||
|
networks: [apps]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
apps:
|
||||||
|
{% if poc_mode == 'single' %}
|
||||||
|
name: bw-services
|
||||||
|
external: true
|
||||||
|
{% else %}
|
||||||
|
name: poc-apps
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PoC BunkerWeb - contact</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
label, input, textarea, button { display: block; width: 100%; margin-bottom: .8rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Contact</h1>
|
||||||
|
<p>Formulaire factice envoyé en GET : la vitrine est statique, c'est BunkerWeb qui
|
||||||
|
inspecte les paramètres. Essayez un message contenant
|
||||||
|
<code><script>alert(1)</script></code> ou <code>' OR 1=1 --</code>.</p>
|
||||||
|
<form method="get" action="contact.html">
|
||||||
|
<label for="nom">Nom</label>
|
||||||
|
<input id="nom" name="nom">
|
||||||
|
<label for="message">Message</label>
|
||||||
|
<textarea id="message" name="message" rows="5"></textarea>
|
||||||
|
<button type="submit">Envoyer</button>
|
||||||
|
</form>
|
||||||
|
<p><a href="index.html">Retour</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PoC BunkerWeb - vitrine</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #222; }
|
||||||
|
h1 { color: #0b7a75; }
|
||||||
|
code { background: #f2f2f2; padding: .1rem .3rem; border-radius: 3px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
td, th { border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>PoC BunkerWeb</h1>
|
||||||
|
<p>Vous voyez cette page : vous avez passé le <strong>challenge antibot JavaScript</strong>
|
||||||
|
de BunkerWeb. Un <code>curl</code> sur cette même URL reste bloqué sur <code>/challenge</code>.</p>
|
||||||
|
|
||||||
|
<h2>Sites du PoC</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Site</th><th>Application</th><th>Fonctionnalités mises en avant</th></tr>
|
||||||
|
<tr><td><a href="https://www.{{ poc_domain }}/">www</a></td><td>nginx statique</td><td>antibot, en-têtes de sécurité, HTTPS forcé</td></tr>
|
||||||
|
<tr><td><a href="https://shop.{{ poc_domain }}/">shop</a></td><td>OWASP Juice Shop</td><td>ModSecurity + OWASP CRS 4, websockets, User-Agents malveillants</td></tr>
|
||||||
|
<tr><td><a href="https://api.{{ poc_domain }}/get">api</a></td><td>go-httpbin</td><td>limitation de débit, méthodes, taille max, bad behavior, règle ModSecurity maison</td></tr>
|
||||||
|
<tr><td><a href="https://bwui.{{ poc_domain }}/">bwui</a></td><td>UI BunkerWeb</td><td>rapports, bannissements, réglages</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Essais rapides dans le navigateur</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="contact.html">Formulaire de contact</a> : saisissez <code><script>alert(1)</script></code> et envoyez.</li>
|
||||||
|
<li><a href="/?file=../../../../etc/passwd">Traversée de répertoire</a> : réponse 403 attendue.</li>
|
||||||
|
</ul>
|
||||||
|
<p><small>Servi par {{ nginx_image }} derrière BunkerWeb {{ bunkerweb_version }}.</small></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
bw_dir: "{{ poc_base_dir }}/bunkerweb"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
- name: Créer le répertoire de la stack BunkerWeb
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ bw_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: Créer le réseau partagé bw-services
|
||||||
|
ansible.builtin.shell: docker network inspect bw-services >/dev/null 2>&1 || { docker network create bw-services && echo created; }
|
||||||
|
register: bw_net
|
||||||
|
changed_when: "'created' in bw_net.stdout"
|
||||||
|
|
||||||
|
- name: Générer le compose BunkerWeb
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: compose.yml.j2
|
||||||
|
dest: "{{ bw_dir }}/compose.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600" # contient les secrets
|
||||||
|
validate: docker compose -f %s config -q
|
||||||
|
|
||||||
|
- name: Démarrer la stack BunkerWeb
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ bw_dir }}"
|
||||||
|
project_name: bunkerweb
|
||||||
|
pull: missing
|
||||||
|
remove_orphans: true
|
||||||
|
state: present
|
||||||
|
register: bw_compose
|
||||||
|
|
||||||
|
- name: Attendre que BunkerWeb réponde sur 443
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: "{{ ansible_host }}"
|
||||||
|
port: 443
|
||||||
|
timeout: 300
|
||||||
|
|
||||||
|
- name: Attendre que le site api réponde au travers du WAF
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "https://{{ ansible_host }}/get"
|
||||||
|
headers:
|
||||||
|
Host: "api.{{ poc_domain }}"
|
||||||
|
validate_certs: false
|
||||||
|
status_code: 200
|
||||||
|
register: bw_probe
|
||||||
|
until: bw_probe.status == 200
|
||||||
|
retries: 30
|
||||||
|
delay: 10
|
||||||
|
|
||||||
|
- name: Écrire le récapitulatif des accès côté contrôleur (credentials/ACCES.txt)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ playbook_dir }}/../credentials/ACCES.txt"
|
||||||
|
mode: "0600"
|
||||||
|
content: |
|
||||||
|
UI BunkerWeb : https://bwui.{{ poc_domain }}/ (WAF {{ ansible_host }})
|
||||||
|
Identifiant : {{ bw_ui_admin_user }}
|
||||||
|
Mot de passe : {{ bw_ui_admin_password }}
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Rappel des accès
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "UI BunkerWeb : https://bwui.{{ poc_domain }}/ , identifiants dans credentials/ACCES.txt"
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# {{ ansible_managed }}
|
||||||
|
# Stack BunkerWeb {{ bunkerweb_version }} : WAF + scheduler + UI + MariaDB + Redis
|
||||||
|
# Mode {{ poc_mode }}{% if poc_mode == 'split' %}, applis sur {{ apps_ip }}{% endif %}
|
||||||
|
|
||||||
|
{% macro upstream(name, site) -%}
|
||||||
|
{%- if site.upstream is match('^https?://') -%}
|
||||||
|
{{ site.upstream }}
|
||||||
|
{%- elif poc_mode == 'single' -%}
|
||||||
|
http://poc-{{ site.upstream }}:{{ apps_internal_ports[site.upstream] }}
|
||||||
|
{%- else -%}
|
||||||
|
http://{{ apps_ip }}:{{ apps_ports[site.upstream] }}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro val(v) %}{{ (v | string | replace('$', '$$')) | to_json }}{% endmacro %}
|
||||||
|
x-db-env: &db-env
|
||||||
|
DATABASE_URI: {{ val('mariadb+pymysql://bunkerweb:' ~ bw_db_password ~ '@bw-db:3306/db') }}
|
||||||
|
|
||||||
|
x-api-env: &api-env
|
||||||
|
API_WHITELIST_IP: "127.0.0.0/8 10.20.30.0/24"
|
||||||
|
|
||||||
|
services:
|
||||||
|
bunkerweb:
|
||||||
|
image: bunkerity/bunkerweb:{{ bunkerweb_version }}
|
||||||
|
container_name: bunkerweb
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:8080/tcp"
|
||||||
|
- "443:8443/tcp"
|
||||||
|
- "443:8443/udp" # HTTP/3 (QUIC)
|
||||||
|
labels:
|
||||||
|
- "bunkerweb.INSTANCE=yes"
|
||||||
|
environment:
|
||||||
|
<<: [*db-env, *api-env]
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
- bw-services
|
||||||
|
|
||||||
|
bw-scheduler:
|
||||||
|
image: bunkerity/bunkerweb-scheduler:{{ bunkerweb_version }}
|
||||||
|
container_name: bw-scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- bunkerweb
|
||||||
|
- bw-db
|
||||||
|
- bw-redis
|
||||||
|
volumes:
|
||||||
|
- bw-storage:/data
|
||||||
|
environment:
|
||||||
|
<<: [*db-env, *api-env]
|
||||||
|
BUNKERWEB_INSTANCES: "bunkerweb"
|
||||||
|
SERVER_NAME: {{ val(bw_sites.keys() | map('regex_replace', '$', '.' ~ poc_domain) | join(' ')) }}
|
||||||
|
# --- Réglages globaux ---
|
||||||
|
{% for k, v in bw_global_settings.items() %}
|
||||||
|
{{ k }}: {{ val(v) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% for name, site in bw_sites.items() %}
|
||||||
|
{% set fqdn = name ~ '.' ~ poc_domain %}
|
||||||
|
# --- {{ fqdn }} ---
|
||||||
|
{{ fqdn }}_USE_REVERSE_PROXY: "yes"
|
||||||
|
{{ fqdn }}_REVERSE_PROXY_URL: "/"
|
||||||
|
{{ fqdn }}_REVERSE_PROXY_HOST: {{ val(upstream(name, site)) }}
|
||||||
|
{{ fqdn }}_SELF_SIGNED_SSL_SUBJ: {{ val('/CN=' ~ fqdn ~ '/') }}
|
||||||
|
{% for k, v in (site.settings | default({})).items() %}
|
||||||
|
{{ fqdn }}_{{ k }}: {{ val(v) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% for k, v in (site.custom_confs | default({})).items() %}
|
||||||
|
{{ fqdn }}_CUSTOM_CONF_{{ k }}: {{ val(v) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
- bw-db
|
||||||
|
|
||||||
|
bw-ui:
|
||||||
|
image: bunkerity/bunkerweb-ui:{{ bunkerweb_version }}
|
||||||
|
container_name: bw-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- bw-db
|
||||||
|
volumes:
|
||||||
|
- bw-ui-data:/data # secrets Flask, clés TOTP et Biscuit
|
||||||
|
environment:
|
||||||
|
<<: *db-env
|
||||||
|
ADMIN_USERNAME: {{ val(bw_ui_admin_user) }}
|
||||||
|
ADMIN_PASSWORD: {{ val(bw_ui_admin_password) }}
|
||||||
|
TZ: "Europe/Paris"
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
- bw-db
|
||||||
|
|
||||||
|
bw-db:
|
||||||
|
image: {{ mariadb_image }}
|
||||||
|
container_name: bw-db
|
||||||
|
restart: unless-stopped
|
||||||
|
command: --max-allowed-packet=67108864
|
||||||
|
environment:
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||||
|
MYSQL_DATABASE: "db"
|
||||||
|
MYSQL_USER: "bunkerweb"
|
||||||
|
MYSQL_PASSWORD: {{ val(bw_db_password) }}
|
||||||
|
volumes:
|
||||||
|
- bw-data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- bw-db
|
||||||
|
|
||||||
|
bw-redis:
|
||||||
|
image: {{ redis_image }}
|
||||||
|
container_name: bw-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--requirepass", {{ val(bw_redis_password) }}, "--save", "60", "1"]
|
||||||
|
volumes:
|
||||||
|
- bw-redis:/data
|
||||||
|
networks:
|
||||||
|
- bw-universe
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bw-data:
|
||||||
|
bw-storage:
|
||||||
|
bw-ui-data:
|
||||||
|
bw-redis:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bw-universe:
|
||||||
|
name: bw-universe
|
||||||
|
ipam:
|
||||||
|
driver: default
|
||||||
|
config:
|
||||||
|
- subnet: 10.20.30.0/24 # seul réseau autorisé à parler à l'API BunkerWeb
|
||||||
|
bw-services:
|
||||||
|
name: bw-services # réseau des applis (mode single), créé par Ansible
|
||||||
|
external: true
|
||||||
|
bw-db:
|
||||||
|
name: bw-db
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
- name: Installer les outils de test
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- curl
|
||||||
|
- jq
|
||||||
|
- sqlmap
|
||||||
|
- nmap
|
||||||
|
- apache2-utils # ab
|
||||||
|
- dnsutils
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
- name: Créer le répertoire des tests
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ poc_base_dir }}/tests"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Déposer le script de tests
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ playbook_dir }}/../tests/bwpoc-tests.sh"
|
||||||
|
dest: "{{ poc_base_dir }}/tests/bwpoc-tests.sh"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Déposer la configuration des tests
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ poc_base_dir }}/tests/bwpoc-tests.env"
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
# Généré par Ansible
|
||||||
|
WAF_IP={{ waf_ip }}
|
||||||
|
DOMAIN={{ poc_domain }}
|
||||||
|
APPS_IP={{ apps_ip }}
|
||||||
|
PORT_WWW={{ apps_ports.www }}
|
||||||
|
PORT_SHOP={{ apps_ports.shop }}
|
||||||
|
PORT_API={{ apps_ports.api }}
|
||||||
|
|
||||||
|
- name: Lien pratique dans le PATH
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "{{ poc_base_dir }}/tests/bwpoc-tests.sh"
|
||||||
|
dest: /usr/local/bin/bwpoc-tests
|
||||||
|
state: link
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
- name: Attendre la fin de cloud-init
|
||||||
|
ansible.builtin.command: cloud-init status --wait
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Installer les paquets de base
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- qemu-guest-agent
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- gnupg
|
||||||
|
- jq
|
||||||
|
- python3-yaml
|
||||||
|
- python3-debian # requis par deb822_repository
|
||||||
|
- chrony
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
- name: Activer l'agent QEMU
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: qemu-guest-agent
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
failed_when: false # inactif hors Proxmox
|
||||||
|
|
||||||
|
- name: Fuseau horaire Europe/Paris
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: /usr/share/zoneinfo/Europe/Paris
|
||||||
|
dest: /etc/localtime
|
||||||
|
state: link
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- name: Résolution locale des sites du PoC (vers le WAF)
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: /etc/hosts
|
||||||
|
marker: "# {mark} PoC BunkerWeb"
|
||||||
|
block: |
|
||||||
|
{{ waf_ip }} www.{{ poc_domain }} shop.{{ poc_domain }} api.{{ poc_domain }} bwui.{{ poc_domain }}
|
||||||
|
|
||||||
|
- name: Vérifier la présence du modèle hosts de cloud-init
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /etc/cloud/templates/hosts.debian.tmpl
|
||||||
|
register: ci_hosts_tmpl
|
||||||
|
|
||||||
|
- name: Persister la résolution si cloud-init gère /etc/hosts
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: /etc/cloud/templates/hosts.debian.tmpl
|
||||||
|
marker: "# {mark} PoC BunkerWeb"
|
||||||
|
block: |
|
||||||
|
{{ waf_ip }} www.{{ poc_domain }} shop.{{ poc_domain }} api.{{ poc_domain }} bwui.{{ poc_domain }}
|
||||||
|
when: ci_hosts_tmpl.stat.exists
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Redémarrer Docker
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: docker
|
||||||
|
state: restarted
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
# Installation de Docker CE depuis le dépôt officiel (Debian 13 trixie)
|
||||||
|
- name: Retirer les paquets Docker de la distribution
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: [docker.io, docker-compose, docker-doc, podman-docker, containerd, runc]
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Déclarer le dépôt Docker
|
||||||
|
ansible.builtin.deb822_repository:
|
||||||
|
name: docker
|
||||||
|
types: [deb]
|
||||||
|
uris: https://download.docker.com/linux/debian
|
||||||
|
suites: ["{{ ansible_distribution_release }}"]
|
||||||
|
components: [stable]
|
||||||
|
architectures: [amd64]
|
||||||
|
signed_by: https://download.docker.com/linux/debian/gpg
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Installer Docker CE et le plugin compose
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- docker-ce
|
||||||
|
- docker-ce-cli
|
||||||
|
- containerd.io
|
||||||
|
- docker-buildx-plugin
|
||||||
|
- docker-compose-plugin
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Limiter la taille des journaux des conteneurs
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": { "max-size": "20m", "max-file": "3" }
|
||||||
|
}
|
||||||
|
notify: Redémarrer Docker
|
||||||
|
|
||||||
|
- name: Démarrer Docker
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: docker
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Ajouter l'utilisateur au groupe docker
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ poc_vm_user }}"
|
||||||
|
groups: docker
|
||||||
|
append: true
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
# Modèle Debian 13 cloud-init, créé une seule fois sur le nœud Proxmox.
|
||||||
|
- name: Le modèle existe-t-il déjà ?
|
||||||
|
ansible.builtin.command: qm status {{ pve_template_vmid }}
|
||||||
|
register: tpl_status
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Créer le modèle
|
||||||
|
when: tpl_status.rc != 0
|
||||||
|
block:
|
||||||
|
- name: Télécharger l'image Debian genericcloud
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "{{ pve_image_url }}"
|
||||||
|
dest: "{{ pve_image_path }}"
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Créer la VM modèle
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
qm create {{ pve_template_vmid }}
|
||||||
|
--name {{ pve_template_name }}
|
||||||
|
--ostype l26 --cpu host --cores 2 --memory 2048
|
||||||
|
--machine q35
|
||||||
|
--scsihw virtio-scsi-single
|
||||||
|
--net0 virtio,bridge={{ pve_bridge }}{{ (',tag=' ~ pve_vlan_tag) if pve_vlan_tag | string | length > 0 else '' }}
|
||||||
|
--serial0 socket --vga serial0
|
||||||
|
--agent enabled=1
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Importer le disque système
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
qm set {{ pve_template_vmid }}
|
||||||
|
--scsi0 {{ pve_storage }}:0,import-from={{ pve_image_path }},discard=on,iothread=1,ssd=1
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Ajouter le lecteur cloud-init et l'ordre de démarrage
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
qm set {{ pve_template_vmid }}
|
||||||
|
--ide2 {{ pve_storage }}:cloudinit
|
||||||
|
--boot order=scsi0
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Convertir en modèle
|
||||||
|
ansible.builtin.command: qm template {{ pve_template_vmid }}
|
||||||
|
changed_when: true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
# Clone et configure les VM du PoC à partir du modèle (exécuté sur le nœud Proxmox).
|
||||||
|
- name: Déposer la clé SSH publique pour cloud-init
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ lookup('ansible.builtin.file', poc_ssh_pubkey_file) }}\n"
|
||||||
|
dest: /root/bwpoc_authorized_keys
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Créer chaque VM
|
||||||
|
ansible.builtin.include_tasks: vm.yml
|
||||||
|
loop: "{{ groups['poc_vms'] | unique }}"
|
||||||
|
loop_control:
|
||||||
|
loop_var: vm_name
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
- name: "{{ vm_name }} : lecture des paramètres"
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
vm: "{{ hostvars[vm_name] }}"
|
||||||
|
|
||||||
|
- name: "{{ vm_name }} : la VM existe-t-elle ?"
|
||||||
|
ansible.builtin.command: qm status {{ vm.vm_id }}
|
||||||
|
register: vm_status
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: "{{ vm_name }} : création"
|
||||||
|
when: vm_status.rc != 0
|
||||||
|
block:
|
||||||
|
- name: "{{ vm_name }} : l'IP {{ vm.ansible_host }} doit être libre"
|
||||||
|
ansible.builtin.command: ping -c 2 -W 1 {{ vm.ansible_host }}
|
||||||
|
register: ip_ping
|
||||||
|
changed_when: false
|
||||||
|
failed_when: ip_ping.rc == 0
|
||||||
|
|
||||||
|
- name: "{{ vm_name }} : clonage du modèle"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
qm clone {{ pve_template_vmid }} {{ vm.vm_id }}
|
||||||
|
--name {{ vm_name }} --full 1 --storage {{ pve_storage }}
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: "{{ vm_name }} : ressources et cloud-init"
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
qm set {{ vm.vm_id }}
|
||||||
|
--cores {{ vm.vm_cores }} --memory {{ vm.vm_memory }}
|
||||||
|
--onboot 1 --tags bwpoc
|
||||||
|
--ciuser {{ poc_vm_user }}
|
||||||
|
--sshkeys /root/bwpoc_authorized_keys
|
||||||
|
--ipconfig0 ip={{ vm.ansible_host }}/{{ poc_net_prefix }},gw={{ poc_net_gateway }}
|
||||||
|
--nameserver "{{ poc_net_dns | join(' ') }}"
|
||||||
|
--searchdomain {{ poc_net_searchdomain }}
|
||||||
|
--ciupgrade 0
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: "{{ vm_name }} : agrandissement du disque à {{ vm.vm_disk_gb }} Go"
|
||||||
|
ansible.builtin.command: qm resize {{ vm.vm_id }} scsi0 {{ vm.vm_disk_gb }}G
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: "{{ vm_name }} : démarrage"
|
||||||
|
ansible.builtin.command: qm start {{ vm.vm_id }}
|
||||||
|
register: vm_start
|
||||||
|
changed_when: vm_start.rc == 0
|
||||||
|
failed_when: vm_start.rc != 0 and 'already running' not in vm_start.stderr
|
||||||
|
|
||||||
|
- name: "{{ vm_name }} : attente du SSH"
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: "{{ vm.ansible_host }}"
|
||||||
|
port: 22
|
||||||
|
timeout: 300
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
Executable
+161
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =====================================================================
|
||||||
|
# Tests fonctionnels du PoC BunkerWeb
|
||||||
|
# Lance une série de requêtes légitimes et malveillantes au travers du
|
||||||
|
# WAF et compare le code HTTP obtenu au comportement attendu.
|
||||||
|
#
|
||||||
|
# Usage : bwpoc-tests.sh [--direct] [--only <motif>]
|
||||||
|
# --direct rejoue les attaques directement sur les applis, sans WAF
|
||||||
|
# (nécessite apps_direct_access_sources, voir README)
|
||||||
|
# --only ne lance que les tests dont le titre contient <motif>
|
||||||
|
#
|
||||||
|
# Variables (fichier bwpoc-tests.env à côté du script, ou environnement) :
|
||||||
|
# WAF_IP, DOMAIN, APPS_IP, PORT_WWW, PORT_SHOP, PORT_API
|
||||||
|
#
|
||||||
|
# ATTENTION : les derniers tests provoquent volontairement le
|
||||||
|
# bannissement de votre IP sur api.<domaine> (BAD_BEHAVIOR_BAN_TIME).
|
||||||
|
# =====================================================================
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[ -f "$SCRIPT_DIR/bwpoc-tests.env" ] && . "$SCRIPT_DIR/bwpoc-tests.env"
|
||||||
|
|
||||||
|
WAF_IP="${WAF_IP:-192.168.200.61}"
|
||||||
|
DOMAIN="${DOMAIN:-bwpoc.internal}"
|
||||||
|
APPS_IP="${APPS_IP:-192.168.200.62}"
|
||||||
|
PORT_SHOP="${PORT_SHOP:-8082}"
|
||||||
|
PORT_API="${PORT_API:-8083}"
|
||||||
|
|
||||||
|
DIRECT=0
|
||||||
|
ONLY=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--direct) DIRECT=1 ;;
|
||||||
|
--only) ONLY="$2"; shift ;;
|
||||||
|
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
||||||
|
*) echo "Option inconnue : $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -t 1 ]; then G=$'\e[32m'; R=$'\e[31m'; Y=$'\e[33m'; B=$'\e[1m'; N=$'\e[0m'; else G=; R=; Y=; B=; N=; fi
|
||||||
|
PASS=0; FAIL=0; INFO=0
|
||||||
|
UA="Mozilla/5.0 (X11; Linux x86_64) bwpoc-tests"
|
||||||
|
|
||||||
|
# Requête au travers du WAF : --resolve évite tout besoin de DNS.
|
||||||
|
# Affiche "<code_http> <url_de_redirection>".
|
||||||
|
waf() {
|
||||||
|
local host="$1" path="$2"; shift 2
|
||||||
|
curl -sk -o /dev/null -w '%{http_code} %{redirect_url}' --max-time 10 \
|
||||||
|
--resolve "$host.$DOMAIN:443:$WAF_IP" --resolve "$host.$DOMAIN:80:$WAF_IP" \
|
||||||
|
-A "$UA" "$@" "https://$host.$DOMAIN$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vérifie qu'un code fait partie de la liste attendue (séparateur |)
|
||||||
|
check() {
|
||||||
|
local title="$1" expected="$2" got="$3" level="${4:-strict}"
|
||||||
|
if [ -n "$ONLY" ] && [[ "$title" != *"$ONLY"* ]]; then return; fi
|
||||||
|
local code="${got%% *}"
|
||||||
|
if [[ "|$expected|" == *"|$code|"* ]]; then
|
||||||
|
printf ' %s[OK]%s %-58s %s\n' "$G" "$N" "$title" "$code"; PASS=$((PASS + 1))
|
||||||
|
elif [ "$level" = info ]; then
|
||||||
|
printf ' %s[INFO]%s %-58s %s (attendu %s)\n' "$Y" "$N" "$title" "$code" "$expected"; INFO=$((INFO + 1))
|
||||||
|
else
|
||||||
|
printf ' %s[KO]%s %-58s %s (attendu %s)\n' "$R" "$N" "$title" "$code" "$expected"; FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
section() { printf '\n%s== %s ==%s\n' "$B" "$1" "$N"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
if [ "$DIRECT" -eq 1 ]; then
|
||||||
|
section "Sans WAF : attaques envoyées directement à $APPS_IP"
|
||||||
|
for t in \
|
||||||
|
"SQLi Juice Shop (recherche produits)|http://$APPS_IP:$PORT_SHOP/rest/products/search?q=%27))%20OR%201%3D1--" \
|
||||||
|
"XSS réfléchie|http://$APPS_IP:$PORT_SHOP/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" \
|
||||||
|
"Méthode PUT sur l'API|http://$APPS_IP:$PORT_API/put"; do
|
||||||
|
title="${t%%|*}"; url="${t#*|}"
|
||||||
|
if [[ "$title" == *PUT* ]]; then
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 -X PUT "$url")
|
||||||
|
else
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url")
|
||||||
|
fi
|
||||||
|
printf ' %-60s %s\n' "$title" "$code"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "Code 000 = port filtré (seul le WAF est autorisé). Sinon, comparez avec le passage par le WAF."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${B}PoC BunkerWeb${N} : WAF $WAF_IP, domaine $DOMAIN"
|
||||||
|
|
||||||
|
section "Transport et socle"
|
||||||
|
check "HTTP redirigé vers HTTPS (api)" "301|302|307|308" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 --resolve "api.$DOMAIN:80:$WAF_IP" "http://api.$DOMAIN/get")"
|
||||||
|
check "Host inconnu refusé (DISABLE_DEFAULT_SERVER)" "000|400|403|404|444" \
|
||||||
|
"$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 --resolve "inconnu.$DOMAIN:443:$WAF_IP" "https://inconnu.$DOMAIN/")"
|
||||||
|
check "Requête légitime sur l'API" "200" "$(waf api /get)"
|
||||||
|
|
||||||
|
section "En-têtes et compression"
|
||||||
|
HDRS=$(curl -sk -D - -o /dev/null --max-time 10 --resolve "api.$DOMAIN:443:$WAF_IP" -A "$UA" "https://api.$DOMAIN/get" | tr -d '\r' | tr 'A-Z' 'a-z')
|
||||||
|
for h in strict-transport-security x-frame-options x-content-type-options content-security-policy referrer-policy; do
|
||||||
|
if grep -q "^$h:" <<<"$HDRS"; then check "En-tête $h ajouté" "yes" "yes"; else check "En-tête $h ajouté" "yes" "no"; fi
|
||||||
|
done
|
||||||
|
if grep -q "^server:" <<<"$HDRS"; then check "En-tête Server masqué" "masqué" "présent" info; else check "En-tête Server masqué" "masqué" "masqué"; fi
|
||||||
|
XFF=$(curl -sk --max-time 10 --resolve "api.$DOMAIN:443:$WAF_IP" -A "$UA" "https://api.$DOMAIN/headers" | jq -r '.headers["X-Forwarded-For"][0] // empty' 2>/dev/null)
|
||||||
|
if [ -n "$XFF" ]; then check "Le backend reçoit X-Forwarded-For ($XFF)" "yes" "yes"; else check "Le backend reçoit X-Forwarded-For" "yes" "no"; fi
|
||||||
|
for enc in br gzip; do
|
||||||
|
CE=$(curl -sk -D - -o /dev/null --max-time 10 -H "Accept-Encoding: $enc" --resolve "api.$DOMAIN:443:$WAF_IP" -A "$UA" "https://api.$DOMAIN/html" \
|
||||||
|
| tr -d '\r' | awk -F': ' 'tolower($1)=="content-encoding"{print $2}')
|
||||||
|
check "Compression $enc" "$enc" "${CE:-aucune}"
|
||||||
|
done
|
||||||
|
|
||||||
|
section "Antibot (www)"
|
||||||
|
RES=$(waf www /)
|
||||||
|
if [[ "$RES" == *challenge* ]] || [[ "${RES%% *}" == 200 && $(curl -sk --max-time 10 --resolve "www.$DOMAIN:443:$WAF_IP" "https://www.$DOMAIN/" | grep -ci challenge) -gt 0 ]]; then
|
||||||
|
check "Un client sans JavaScript reste bloqué sur /challenge" "yes" "yes"
|
||||||
|
else
|
||||||
|
check "Un client sans JavaScript reste bloqué sur /challenge" "yes" "no ($RES)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "WAF ModSecurity + OWASP CRS 4 (shop)"
|
||||||
|
check "Page d'accueil Juice Shop" "200" "$(waf shop /)"
|
||||||
|
check "SQLi : recherche produits ')) OR 1=1--" "403" "$(waf shop "/rest/products/search?q=%27))%20OR%201%3D1--")"
|
||||||
|
check "XSS : <script>alert(1)</script>" "403" "$(waf shop "/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E")"
|
||||||
|
check "Traversée de répertoire ../../etc/passwd" "403" "$(waf shop "/?file=..%2F..%2F..%2F..%2Fetc%2Fpasswd")"
|
||||||
|
check "Injection de commande ;cat /etc/passwd" "403" "$(waf api "/get?cmd=%3Bcat%20%2Fetc%2Fpasswd")"
|
||||||
|
check "Règle ModSecurity maison ?poc=attack" "403" "$(waf api "/get?poc=attack")"
|
||||||
|
|
||||||
|
section "Listes noires"
|
||||||
|
check "User-Agent maison PoC-EvilBot (BLACKLIST_USER_AGENT)" "403" "$(waf shop / -A "PoC-EvilBot/1.0")"
|
||||||
|
check "User-Agent sqlmap (liste communautaire)" "403" "$(waf shop / -A "sqlmap/1.8#stable (https://sqlmap.org)")" info
|
||||||
|
|
||||||
|
section "Contraintes protocolaires (api)"
|
||||||
|
check "Méthode PUT interdite (ALLOWED_METHODS)" "405" "$(waf api /put -X PUT -d x=1)"
|
||||||
|
check "Corps de 2 Mo refusé (MAX_CLIENT_SIZE=1m)" "413" \
|
||||||
|
"$(head -c 2097152 /dev/zero | tr '\0' 'a' | curl -sk -o /dev/null -w '%{http_code}' --max-time 20 \
|
||||||
|
--resolve "api.$DOMAIN:443:$WAF_IP" -A "$UA" -H 'Content-Type: text/plain' --data-binary @- "https://api.$DOMAIN/post")"
|
||||||
|
|
||||||
|
section "Limitation de débit puis bad behavior (api)"
|
||||||
|
echo " Envoi de 40 requêtes en parallèle (LIMIT_REQ_RATE=5r/s)..."
|
||||||
|
CODES=$(seq 1 40 | xargs -P 20 -I{} curl -sk -o /dev/null -w '%{http_code}\n' --max-time 10 \
|
||||||
|
--resolve "api.$DOMAIN:443:$WAF_IP" -A "$UA" "https://api.$DOMAIN/get?n={}" | sort | uniq -c | tr '\n' ' ')
|
||||||
|
echo " Répartition des codes : $CODES"
|
||||||
|
if grep -qE '\b429\b' <<<"$CODES"; then check "Des requêtes ont été limitées (429)" "yes" "yes"; else check "Des requêtes ont été limitées (429)" "yes" "no"; fi
|
||||||
|
echo " Envoi de 12 requêtes en erreur 404 (BAD_BEHAVIOR_THRESHOLD=10)..."
|
||||||
|
for i in $(seq 1 12); do waf api "/status/404?i=$i" >/dev/null; done
|
||||||
|
sleep 2
|
||||||
|
check "IP bannie : requête légitime désormais refusée" "403" "$(waf api /get)"
|
||||||
|
check "Bannissement limité au service api (shop répond)" "200" "$(waf shop /)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
printf '\n%sBilan%s : %s%d OK%s, %s%d KO%s, %d info\n' "$B" "$N" "$G" "$PASS" "$N" "$R" "$FAIL" "$N" "$INFO"
|
||||||
|
MYIP=$(curl -sk --max-time 5 --resolve "shop.$DOMAIN:443:$WAF_IP" -A "$UA" -o /dev/null -w '%{local_ip}' "https://shop.$DOMAIN/")
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Votre IP ($MYIP) est bannie de api.$DOMAIN pendant BAD_BEHAVIOR_BAN_TIME.
|
||||||
|
Pour lever le bannissement : UI BunkerWeb, menu Bans, ou sur la VM WAF :
|
||||||
|
docker exec bw-scheduler bwcli unban $MYIP
|
||||||
|
EOF
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
Reference in new issue
Block a user