Un SOC commence par une carte

Mon run-book se présente lui-même comme une « CMDB simplifiée ». C'est un fichier que je maintiens depuis des mois, qui liste les hôtes, les services, les ports et les dépendances. Le jour où j'ai voulu écrire sur mon SOC, c'est le premier document que j'ai rouvert.

Parce qu'un SOC qui ne sait pas ce qu'il protège ne détecte rien. Il produit du bruit, et le bruit finit toujours par être ignoré.

J'ai donc repris mon schéma de défense en profondeur, celui que j'ai en tête depuis que j'ai monté ce lab, et je l'ai confronté au code. Rôle par rôle, chart par chart, Jenkinsfile par Jenkinsfile. Chaque contrôle devait produire sa preuve, sinon il perdait son statut.

Le résultat est moins flatteur que le schéma, et beaucoup plus utile.

Le parcours de ce billet suit cet exercice. D'abord la carte générale, sept couches du câble au tableau de bord. Puis le chemin d'un attaquant à travers ces couches, avec les deux endroits où il passerait sans trop d'effort. Vient ensuite un zoom sur la couche applicative et le pipeline, là où j'ai le plus investi, avant la télémétrie et la réponse, c'est-à-dire le SOC au sens strict. Et pour finir, le bilan chiffré et la liste de ce qui manque.

Si les notions de SOC, de SIEM et de SOAR ne sont pas familières, la théorie est traitée dans SOC, SIEM et SOAR, et le principe de défense en profondeur dans Fondamentaux : DICP, risque et défense en profondeur. Ici, on regarde ce que ça donne sur du vrai matériel, avec de vraies lacunes.

Un schéma d'architecture qui ne distingue pas ce qui tourne de ce qui est prévu n'est pas une carte. C'est une plaquette commerciale.

Méthode et légende

Chaque badge de cet article a été gagné, pas attribué. La méthode tient en trois passes.

Première passe : le code. J'ai cherché les directives dans les rôles Ansible, les valeurs dans les charts Helm, les étapes dans les Jenkinsfiles, les contraintes dans les changelogs Liquibase. Si un contrôle a une occurrence dans un dépôt, il est versionné, donc reproductible.

## Un contrôle sans occurrence dans un dépôt retombe en badge « manuel »
rg -l "SecRuleEngine On" roles/
rg -l "PeerAuthentication" manifests/
rg -l "@PreAuthorize" --glob "*.java" | wc -l

Deuxième passe : le run-book de juillet 2026, pour tout ce qui n'existe que dans une interface web. Les règles de l'UDM Pro, les seuils de Harbor, les entrées Graylog. Ces contrôles existent réellement, mais ils ne survivraient pas à une réinstallation sans que quelqu'un se souvienne de les recliquer.

Troisième passe : l'honnêteté. Quand un contrôle est déployé mais que je ne sais pas s'il bloque ou s'il se contente d'observer, il ne mérite pas un badge vert.

Badge Signification Preuve exigée
En place et versionné Rôle Ansible, chart Helm, Jenkinsfile ou migration Liquibase
🔶 En place mais partiel, non bloquant, ou mode effectif à confirmer Trace dans le code, sans garantie d'effet
Configuré à la main dans une interface, non versionné Entrée dans le run-book
Cible du schéma, pas encore en place Aucune

Cette légende sert dans tout l'article, y compris dans les schémas : le cuivre plein pour le versionné, le crème bordé pour le partiel, le crème clair pour le manuel, le rouge pour le manquant.

Le badge le plus intéressant n'est pas le rouge, c'est le blanc. Un contrôle cliqué à la main n'existe qu'aussi longtemps que la mémoire de celui qui l'a cliqué.

La carte générale : sept couches

Sept couches, du câble jusqu'au tableau de bord. Chacune a sa logique propre, ses outils et son niveau de maturité. Le schéma ci-dessous montre les contrôles représentatifs de chaque couche, avec leur statut réel.

flowchart TB subgraph L1["L1 Physique"] A1["RAID 1 matériel : PERC H345, iDRAC9"]:::manuel A2["ESXi 7.0U3, une dizaine de VM"]:::manuel A3["NAS QNAP : export NFS et PVC"]:::manuel A4["Chiffrement du NAS, TPM"]:::manque end subgraph L2["L2 Périmètre"] B1["UDM Pro : pare-feu, IDS/IPS, WireGuard"]:::manuel B2["nginx et ModSecurity en bordure"]:::ok B3["TLS Let's Encrypt, CA interne Ansible"]:::ok B4["Filtrage géographique sur l'appliance"]:::manuel end subgraph L3["L3 Réseau"] C1["VLAN : inter-VLAN interdit par défaut"]:::manuel C2["PowerDNS : zones devops.lab et k8s.lab"]:::ok C3["Istio Ambient : ztunnel, mTLS STRICT"]:::ok C4["NetworkPolicies : chart Kafka seulement"]:::partiel end subgraph L4["L4 Hôtes"] D1["Harbor et Trivy : scan des images"]:::ok D2["Secrets : Kubernetes, Jenkins, Passbolt"]:::ok D3["securityContext commenté dans les charts"]:::partiel D4["Durcissement CIS, fail2ban, auditd"]:::manque end subgraph L5["L5 Applications"] E1["ModSecurity et OWASP CRS v4.0.0"]:::ok E2["Keycloak : OIDC, realm dédié"]:::ok E3["APISIX : quotas et restriction des routes"]:::ok E4["Vérification JWT déportée aux backends"]:::partiel end subgraph L6["L6 Données"] F1["Audit immuable : checksum SHA-256 chaîné"]:::ok F2["RGPD : masquage et purge Temporal"]:::ok F3["Sauvegardes nocturnes vers le NAS"]:::partiel F4["Chiffrement au repos et RLS"]:::manque end subgraph L7["L7 SOC"] G1["Graylog : 13 hôtes en rsyslog"]:::ok G2["avuru obs : OTLP, eBPF, ClickHouse"]:::ok G3["Wazuh, Zabbix, ECK"]:::partiel G4["Streams et définitions d'alerte à créer"]:::manuel end L1 --> L2 L2 --> L3 L3 --> L4 L4 --> L5 L5 --> L6 L6 --> L7 classDef ok fill:#d89253,stroke:#5f5e5a,color:#1a1a1a classDef partiel fill:#ede9e1,stroke:#b5651d,color:#1a1a1a classDef manuel fill:#f5f2ec,stroke:#5f5e5a,color:#3a3a3a classDef manque fill:#a0413e,stroke:#5f5e5a,color:#f5f2ec

Voici maintenant le détail, couche par couche. La couche 7 a sa propre section plus bas.

L1 Physique

C'est la couche que je maîtrise le mieux et que je versionne le moins. Tout se pilote depuis des interfaces constructeur : iDRAC pour le contrôleur RAID, l'interface ESXi pour les VM, celle de QNAP pour le NAS.

Le NAS mérite une mention particulière : son export NFS /lab ne sert pas qu'à mes archives, il porte aussi les PVC du cluster Kubernetes. Un volume monté par des dizaines de pods, sans chiffrement au repos.

Contrôle Outil Statut Où c'est défini
RAID 1 matériel Dell PowerEdge T350, contrôleur PERC H345 iDRAC9
Virtualisation ESXi 7.0U3, une dizaine de VM Interface ESXi
Stockage partagé et PVC NAS QNAP TS-453D, export NFS /lab Interface QNAP
Continuité électrique Onduleurs APC et Eaton, arrêt contrôlé Interface des onduleurs
Chiffrement du stockage, TPM aucun nulle part

Le RAID 1 vient d'une migration en urgence racontée dans Migration urgence vers RAID 1 SSD. La disponibilité de cette couche a coûté un week-end. Sa confidentialité, elle, n'a encore rien coûté du tout.

L2 Périmètre

Deux gardiens à l'entrée. L'UDM Pro fait le travail réseau : routage, pare-feu, IDS/IPS, DHCP multi-VLAN, portail WireGuard. Le reverse proxy nginx sur vmmgnt fait le travail applicatif, avec ModSecurity et la terminaison TLS.

Le filtrage géographique se fait au niveau de l'appliance, pas au niveau de nginx. Ce n'est pas un oubli : le module geoip est explicitement retiré des options de compilation du rôle. Un choix que j'assume, mais qui déporte un contrôle du versionné vers le cliqué.

Contrôle Outil Statut Où c'est défini
Routage, pare-feu, IDS/IPS, DHCP, WireGuard UDM Pro Interface UniFi
Politique inter-VLAN « tout interdit par défaut » UDM Pro Interface UniFi
Reverse proxy, WAF et terminaison TLS nginx, ModSecurity, Let's Encrypt Rôle Ansible
CA interne distribuée install-ca-cert-ansible, avuru-ca-cluster-issuer Rôle Ansible et chart
Filtrage géographique UDM Pro, geoip retiré du build nginx Interface UniFi
Isolation de la DMZ VLAN dédié Interface UniFi

La CA interne est le contrôle dont je suis le plus content sur cette couche. Un rôle Ansible la distribue sur les hôtes, un ClusterIssuer la présente au cluster. Résultat : les certificats internes sont valides partout, et personne dans la maison n'a pris l'habitude de cliquer sur « continuer malgré l'avertissement ».

L3 Réseau

Une dizaine de VLAN : management, IoT, VPN, main, guest, CCTV, pro, lab et DMZ. Le plan que j'avais publié en 2025 sur la topologie réseau du lab a évolué depuis, et je ne redonne pas les sous-réseaux ici.

Au-dessus, le cluster ajoute sa propre couche réseau. Istio en mode Ambient, avec ztunnel déployé en DaemonSet et du mTLS automatique via HBONE, sans sidecar. Le PeerAuthentication est en mode STRICT sur les deux namespaces applicatifs, et des AuthorizationPolicy isolent les namespaces entre eux, avec une règle explicite deny-staging-to-prod.

Le détail qui me gêne : ces manifestes sont appliqués à la main. Ils existent, ils sont dans un dépôt, mais aucun rôle ni aucun chart ne les pose. Une réinstallation du cluster les oublierait.

Contrôle Outil Statut Où c'est défini
Segmentation en une dizaine de VLAN UDM Pro Interface UniFi
Pare-feu interne et IDS UDM Pro Interface UniFi
Résolution interne devops.lab et k8s.lab PowerDNS Rôle Ansible, mis à jour par la CI
Journalisation des requêtes DNS PowerDNS nulle part
mTLS de service à service Istio Ambient 1.30.3, ztunnel, HBONE, PeerAuthentication STRICT Manifestes appliqués à la main
Isolation namespaces, deny-staging-to-prod AuthorizationPolicy Istio Manifestes appliqués à la main
NetworkPolicies Kubernetes Chart Kafka uniquement 🔶 Chart Helm Kafka

Sur les NetworkPolicies, je suis à moitié honnête quand je dis « partiel ». Seul le chart Kafka en embarque. Aucun namespace applicatif n'en a. Ce sont les AuthorizationPolicy d'Istio qui font le travail d'isolation, à la couche 7 plutôt qu'à la couche 3. Ça marche, mais ça repose entièrement sur le mesh : si ztunnel tombe ou si un pod sort du mesh, il n'y a plus rien en dessous.

L'absence de journalisation DNS est un angle mort classique. Un résolveur interne qui voit tout et n'écrit rien, c'est une caméra débranchée.

L4 Hôtes

La couche la plus rouge de la carte, et j'en revendique une partie.

Le durcissement CIS, l'authentification SSH par clé seule, fail2ban et auditd : rien de tout ça n'est en place. L'intégrité des fichiers est couverte autrement, par les agents Wazuh, ce qui compense partiellement l'absence d'auditd sans la remplacer.

Les mises à jour automatiques sont absentes par choix. Après un incident où une mise à jour a cassé la chaîne ModSecurity en pleine nuit, j'ai posé un apt-mark hold sur les paquets critiques et je fais les montées de version manuellement, dans une fenêtre choisie. Un badge rouge assumé vaut mieux qu'un badge vert qui casse la production le mardi matin.

Contrôle Outil Statut Où c'est défini
Durcissement système (CIS, clé SSH, fail2ban, auditd) aucun nulle part
Mises à jour non surveillées apt-mark hold, fenêtre manuelle Choix documenté au run-book
Conteneurs non privilégiés securityContext commenté, limites CPU et mémoire posées 🔶 Charts Helm
Scan des images Harbor v2.14.0 et Trivy Rôle Ansible
Seuil de blocage CVE critique Harbor Interface Harbor
Gestion des secrets Secrets Kubernetes, credentials Jenkins, Passbolt, yopass, Ansible Vault Charts, Jenkins, un rôle Ansible
Coffre centralisé (Vault, sealed-secrets) aucun nulle part

Le securityContext mérite une explication. Il est présent dans tous mes charts, correctement écrit, avec les bons champs. Et commenté. Ligne par ligne. Le vestige d'un débogage qui a fini par devenir la norme, pendant que les limites CPU et mémoire, elles, sont restées actives.

Côté secrets, la situation est correcte sans être bonne : Passbolt et yopass tournent, Ansible Vault chiffre un rôle, Jenkins gère ses credentials. Mais des mots de passe par défaut traînent encore dans des defaults/main.yml, et il n'y a pas de coffre centralisé qui ferait autorité sur l'ensemble.

L5 Applications

C'est la couche où j'ai le plus investi, et ça se voit. Trois gardiens successifs : le WAF en bordure, la gateway d'API, et l'autorisation dans le code.

ModSecurity v3.0.8 tourne devant nginx 1.24 avec l'OWASP CRS v4.0.0 et SecRuleEngine On posé par le rôle. Son journal d'audit JSON part vers Graylog. Ce que je n'ai pas revalidé depuis l'incident de mise à jour, c'est son mode effectif : est-ce qu'il bloque vraiment, ou est-ce qu'il se contente de journaliser ? Tant que je n'ai pas rejoué un test, le badge reste orange.

Contrôle Outil Statut Où c'est défini
WAF applicatif ModSecurity v3.0.8, nginx 1.24, OWASP CRS v4.0.0 Rôle Ansible
Mode effectif du WAF (blocage ou détection) ModSecurity 🔶 À revalider
Journal d'audit du WAF JSON expédié vers Graylog Rôle Ansible
Authentification centralisée Keycloak 24.0.1, OIDC, realm dédié Rôle Ansible
MFA utilisateur Keycloak nulle part
Quotas par utilisateur APISIX 3.15, limit-count adossé à Redis Manifestes APISIX
Restriction des routes d'orchestration APISIX ip-restriction Manifestes APISIX
Admin API de la gateway ClusterIP restreinte au CIDR des pods Manifestes APISIX
Vérification JWT à la gateway APISIX 🔶 Désactivée, validation déportée aux backends
Autorisation applicative Spring Security, @PreAuthorize dans 84 fichiers Code des services
Filtre d'appartenance Spring, mode audit par défaut 🔶 Code des services

Le rate limiting est la partie dont je suis le plus satisfait. La clé de comptage est calculée depuis le JWT, avec repli sur l'adresse source quand le jeton est absent, ce qui évite qu'un utilisateur légitime derrière un NAT partagé se fasse pénaliser par son voisin. Neuf budgets distincts, de 20 à 300 requêtes par minute selon la classe de route : les routes d'authentification sont beaucoup plus serrées que les routes de lecture.

En revanche, la vérification du JWT au niveau de la gateway est désactivée depuis juillet 2026. Mes jetons viennent d'un UAA maison qui n'expose pas de JWKS, et APISIX ne pouvait donc pas valider les signatures. Ce sont les backends qui valident, chacun de leur côté. La compensation fonctionne, mais elle déplace le contrôle au lieu de le remplacer, et j'y reviens dans la section suivante.

Dernier détail sur cette couche : le filtre d'appartenance, qui vérifie qu'un utilisateur ne manipule que ses propres ressources, est configuré en mode audit par défaut. Il journalise les violations, il ne les bloque pas. Le principe est traité plus largement dans Sécurité des API et OWASP Top 10.

L6 Données

Une base PostgreSQL 17 partagée sur vm.db.lab, une quinzaine de bases pour tout le lab. C'est mon principal point de défaillance unique, et je le sais depuis le jour où je l'ai créée. Son schéma est intégralement versionné par Liquibase, ce qui est la bonne nouvelle. Son pg_hba est beaucoup trop permissif, ce qui est la mauvaise.

Contrôle Outil Statut Où c'est défini
Schéma et migrations versionnés PostgreSQL 17 sur vm.db.lab, Liquibase Changelogs Liquibase
Contrôle d'accès réseau à la base pg_hba 🔶 Rôle Ansible, trop permissif
Chiffrement au repos (TDE) aucun Présent au document d'architecture
TLS en transit Let's Encrypt en bordure, mTLS dans le mesh 🔶 Pas de sslmode côté PostgreSQL
Sécurité au niveau des lignes aucun Isolation faite en Java
Masquage des données personnelles Annotation dédiée côté audit, test de non-régression Code du service d'audit
Cycle de vie RGPD Workflow Temporal : suppression douce, 30 jours de grâce, purge et anonymisation Code du workflow
Sauvegardes Dump complet à 02:30, rétention 7 jours et 4 semaines vers le NAS 🔶 Tâche planifiée
Journal d'audit immuable Kafka avuru.audit.*, outbox transactionnel, checksum SHA-256 chaîné Migrations Liquibase et code

Le chiffrement au repos figure dans mon document d'architecture depuis le premier jour. Il n'a jamais atteint le moteur. Même chose pour la sécurité au niveau des lignes : l'isolation entre utilisateurs existe, mais elle est réalisée en Java, par des gardes d'appartenance. Si quelqu'un ouvre un psql avec le compte applicatif, il voit tout.

Le TLS en transit est un cas d'école de faux sentiment de sécurité. Let's Encrypt protège la bordure, le mesh chiffre le trafic entre pods, et pourtant les connexions à PostgreSQL n'ont pas de sslmode. Trois quarts du chemin sont chiffrés.

Sur les sauvegardes, la restauration a été testée en juillet 2026 et elle fonctionne. Mais il n'y a ni copie hors site, ni restauration à un instant donné. La règle 3-2-1 n'est donc pas tenue, et il faut le dire ainsi plutôt que d'écrire « sauvegardes en place ».

Le contrôle dont je suis le plus fier est le journal d'audit. Les événements partent en Kafka sur les topics avuru.audit.* via un outbox transactionnel, sont consommés par un service dédié, et chaque enregistrement porte un checksum SHA-256 qui intègre celui de l'enregistrement précédent. Toute modification a posteriori casse la chaîne. Un trigger BEFORE UPDATE OR DELETE refuse les modifications, un REVOKE UPDATE, DELETE retire le droit au compte applicatif, et un partitionnement annuel est prévu pour tenir plus de sept ans de rétention.

Et pour les données personnelles, la règle est simple : elles n'entrent pas dans les événements d'audit. Une annotation dédiée assure le masquage, et un test de non-régression vérifie la règle à chaque build.

Une couche « données » se juge sur ce qui se passe quand toutes les autres ont échoué. Chez moi, l'audit tiendrait. Le reste serait lisible.

Le chemin d'un attaquant

La carte statique dit ce qui existe. Elle ne dit pas ce qui résiste. Le schéma suivant suit un attaquant depuis Internet, couche par couche, avec à chaque étape la question qui compte.

flowchart TD ATT["Attaquant (Internet)"]:::manque P1["UDM Pro : IDS/IPS, deny par défaut"]:::manuel Q1{"Contourné ?"} P2["nginx et ModSecurity, CRS v4"]:::ok Q2{"Contourné ?"} P3["APISIX : quotas, restriction des routes"]:::ok Q3{"Contourné ?"} P4["ztunnel : mTLS STRICT, deny staging vers prod"]:::ok Q4{"Contourné ?"} P5["Harbor : image bloquée si CVE critique"]:::ok Q5{"Contourné ?"} P6["Audit immuable : checksum chaîné"]:::ok P7["Graylog : alerte Discord et n8n"]:::ok STOP["Détecté et journalisé"]:::ok ATT --> P1 P1 --> Q1 Q1 -- "non" --> STOP Q1 -- "oui" --> P2 P2 --> Q2 Q2 -- "non" --> STOP Q2 -- "oui" --> P3 P3 --> Q3 Q3 -- "non" --> STOP Q3 -- "oui" --> P4 P4 --> Q4 Q4 -- "non" --> STOP Q4 -- "oui" --> P5 P5 --> Q5 Q5 -- "non" --> STOP Q5 -- "oui" --> P6 P6 --> P7 P7 --> STOP classDef ok fill:#d89253,stroke:#5f5e5a,color:#1a1a1a classDef partiel fill:#ede9e1,stroke:#b5651d,color:#1a1a1a classDef manuel fill:#f5f2ec,stroke:#5f5e5a,color:#3a3a3a classDef manque fill:#a0413e,stroke:#5f5e5a,color:#f5f2ec

Sur le papier, la chaîne tient. En pratique, deux maillons sont plus faibles que le schéma ne le laisse croire.

Le premier, c'est le mode effectif de ModSecurity. La directive SecRuleEngine On est bien dans le rôle, le CRS v4.0.0 est chargé, les journaux partent vers Graylog. Mais je n'ai pas rejoué de test de blocage depuis l'incident de mise à jour, et entre un WAF qui bloque et un WAF qui se contente de regarder passer, il y a toute la différence entre une défense et un témoin. Tant que je n'ai pas envoyé une requête volontairement malveillante pour voir si elle repart en 403, ce maillon reste une hypothèse.

Le second, c'est la vérification du JWT à la gateway. Chaque backend valide le jeton qu'il reçoit, et les routes d'orchestration internes sont restreintes par ip-restriction. Ces deux mesures compensent. Mais un contrôle centralisé et un contrôle distribué n'ont pas le même profil de risque : il suffit qu'un service oublie sa configuration de validation pour que le trou existe, et rien à la gateway ne le rattrapera. Une compensation n'est pas une équivalence.

Le reste de la chaîne est plus solide. Le mTLS STRICT du mesh signifie qu'un pod compromis dans staging ne peut pas parler à la production, y compris s'il connaît le nom du service : la règle deny-staging-to-prod est explicite. Et une image portant une CVE critique n'arrive pas jusqu'au cluster, parce que Harbor la refuse avant.

La question utile n'est pas « combien de couches j'ai empilées » mais « laquelle produit une alerte quand la précédente tombe ».

Zoom : la couche applicative et le pipeline

Une bonne partie de mes contrôles applicatifs ne s'exécutent pas en production. Ils s'exécutent avant, dans le pipeline. C'est là que se joue la différence entre un lab qui se protège et un lab qui compile.

flowchart LR C["commit"]:::ok GL["GitLab CI (non bloquant) : Semgrep, Gitleaks, Dependency-Check, Checkov, ZAP baseline"]:::partiel J["Jenkins : build, tests, ArchUnit, SonarQube"]:::ok H["Harbor : Trivy, blocage CVE critique"]:::ok S["staging : suite Karate sécurité"]:::ok P["production"]:::ok N["avuru-appsec nocturne : Semgrep, Syft, Grype, Trivy, Checkov, OPA"]:::partiel D["DefectDojo"]:::partiel C --> GL GL --> J J --> H H --> S S --> P C -.-> N N -.-> D classDef ok fill:#d89253,stroke:#5f5e5a,color:#1a1a1a classDef partiel fill:#ede9e1,stroke:#b5651d,color:#1a1a1a classDef manuel fill:#f5f2ec,stroke:#5f5e5a,color:#3a3a3a classDef manque fill:#a0413e,stroke:#5f5e5a,color:#f5f2ec

En reprenant la grille de familles d'outils du panorama SAST, DAST, SCA et fuzzing, voilà ce que le lab couvre réellement.

Famille Outil au lab Statut Bloquant ?
SAST code Semgrep, règles OWASP Top 10, dans GitLab CI 🔶 Non
SAST code SonarQube, Jenkinsfile dédié dans environ 18 dépôts 🔶 Oui dans son pipeline, absent du flux standard
SAST architecture ArchUnit, 30 règles Oui
SAST IaC Checkov 🔶 Non
SAST secrets Gitleaks 🔶 Non, et aucun hook pre-commit
DAST ZAP baseline, conditionné à une variable 🔶 Non
DAST et API Karate, 41 fichiers de scénarios Oui
SCA Dependency-Check 🔶 Non
SCA Syft et Grype, runner nocturne 🔶 Non
SCA et conteneurs Harbor et Trivy Oui, au seuil critique
Fuzzing aucun harness Non

ArchUnit est la surprise de cet exercice. Je l'avais mis en place pour des raisons d'architecture, pas de sécurité, et il s'avère être un de mes contrôles les plus efficaces. Sur ses 30 règles, un jeu est explicitement orienté sécurité : interdiction d'accéder à la requête HTTP depuis une classe de service, validation obligatoire sur les contrôleurs, et obligation d'exposer un identifiant externe plutôt que l'identifiant technique dans les DTO. Cette dernière règle élimine une classe entière de fuites par énumération, à la compilation, sans qu'un humain ait à y penser.

Karate est l'autre bonne surprise. Les 41 fichiers de scénarios ne testent pas que le fonctionnel : l'authentification (401 sur jeton invalide, absence d'énumération d'utilisateurs), les portées (403 et non 500 quand un scope manque, nuance qui trahit beaucoup d'implémentations), un garde anti-IDOR sur la suppression RGPD, et les quotas.

Le reste alerte. Semgrep, Gitleaks, Checkov, ZAP, Dependency-Check, Syft, Grype : tous tournent, aucun n'interrompt un build. Et le fuzzing est simplement absent. J'ai bien k6 qui envoie de la charge et qui valide au passage que le rate limiting renvoie ses 429, mais faire du volume sur des entrées valides n'est pas du fuzzing.

Aujourd'hui, la ligne qui bloque vraiment tient en trois noms : ArchUnit, Karate et Harbor. Tout le reste produit du signal.

Et l'IA ?

Le lab expose un endpoint MCP et fait tourner un service d'IA, donc la question se pose déjà.

Ce qui existe : un budget de rate limiting dédié aux routes MCP, des événements d'audit spécifiques pour la création de clé MCP, sa révocation, son rejet et les connexions, et une route de recette qui fait passer les corps JSON-RPC par nginx et ModSecurity. Ce dernier point vient avec un risque documenté de faux positifs du CRS sur les charges JSON imbriquées, que j'ai noté avant même de l'observer.

Ce qui manque : aucun red-teaming de LLM, aucun scan des serveurs MCP installés. Un serveur MCP est du code tiers avec des droits d'exécution, et je le traite aujourd'hui avec moins de rigueur qu'une image Docker. La section « Ouverture » du panorama d'outils liste les pistes de cette famille émergente.

Un outil déployé n'est pas un outil qui protège. Sur les onze outils du tableau, trois bloquent réellement une livraison.

Le SOC : télémétrie, corrélation, réponse

Un SOC, c'est trois choses : voir, comprendre, réagir. Je vois beaucoup, je comprends moyennement, je réagis rarement.

La collecte est la partie la plus mûre. Treize hôtes expédient l'intégralité de leurs journaux vers Graylog, et le WAF y ajoute son propre flux avec un tag dédié qui permet de le filtrer.

## Ce que porte chaque hôte : tout part vers Graylog en UDP
*.* @graylog.monitor.lab:10514
Source Transport Destination Statut
rsyslog *.* sur 13 hôtes UDP 10514 graylog.monitor.lab
Journal d'audit ModSecurity, tag dédié UDP 10514 graylog.monitor.lab
Événements d'audit applicatifs GELF UDP 12201 graylog.monitor.lab 🔶
Agents Wazuh 4.12 : intégrité, rootcheck, ports ouverts Agent Manager Wazuh 🔶
Traces, logs, métriques, profils OTLP 4317 et 4318, capteur eBPF avuru obs, ClickHouse
Télémétrie du mesh Istio Kiali
Métriques Scrape Prometheus, limité au mesh 🔶
Supervision infra et matériel Agents Zabbix 7.0 Zabbix 🔶
Collecte ECK 9.2.1 Beats Elasticsearch, non exploité 🔶

Le GELF n'est activé que sur un seul service, en recette. Wazuh tourne, mais je ne saurais pas dire de tête sur combien d'hôtes exactement, donc le périmètre reste à confirmer. Et ECK collecte consciencieusement des données que personne ne regarde depuis des mois, ce qui est la définition même d'un badge orange.

avuru obs, la brique que j'ai écrite

Au milieu de cette collection d'outils, il y a une brique que je n'ai pas installée mais développée. avuru obs est la plateforme d'observabilité que j'ai substituée à Jaeger dans ce lab, publiée en open source sous licence AGPL sur avuruobs.io.

Elle réunit sur un seul moteur de stockage, ClickHouse, ce que j'avais réparti sur une constellation d'outils : les traces (capteur eBPF sans modification de code, plus l'ingestion OTLP classique), les logs corrélés par identifiant de trace, les métriques RED, le profiling continu, le suivi des erreurs, la santé des services et l'alerting.

Elle embarque aussi un module « green » qui attribue une consommation en Wh et une empreinte en gCO2e par service, calculées à partir de Kepler et des compteurs RAPL du processeur, avec des budgets carbone par service et un export exploitable pour un reporting CSRD. Les suites d'observabilité que j'ai croisées ne mesurent pas cet axe, alors que la directive le réclame chez les entreprises qui y sont soumises.

Concrètement, dans ce lab, la télémétrie du mesh Istio et celle de la gateway APISIX pointent toutes les deux vers avuruobs-gateway.avuru-obs.svc.cluster.local. Le Jaeger d'origine reste un chemin de repli documenté dans le rôle Ansible, au cas où.

La corrélation et la réponse

C'est là que le SOC s'arrête net.

Contrôle Outil Statut Où c'est défini
Canal d'alerte et chaîne d'automatisation Discord et n8n Configuration versionnée
Streams T0/T1, T2/T3, échecs et définitions d'alerte Graylog À créer dans l'interface
Remédiation « ModSecurity muet » Graylog vers n8n Workflow n8n

Le canal Discord existe et la chaîne vers n8n fonctionne. Mais les streams qui devraient trier les journaux par criticité, et les définitions d'alerte qui devraient les déclencher, restent à créer. J'ai donc un tuyau parfaitement fonctionnel, branché sur presque rien.

Le seul workflow de remédiation qui existe aujourd'hui est aussi le plus révélateur de mes priorités : Graylog détecte que ModSecurity a cessé d'émettre des journaux, déclenche n8n, qui relance la collecte. Autrement dit, la seule chose que mon SOC sait réparer tout seul, c'est son propre aveuglement.

Ce n'est pas ridicule. C'est même le bon premier automatisme, parce qu'un capteur muet est le pire des états : celui où l'absence d'alerte est interprétée comme une absence de problème.

Une alerte qui n'a pas de destinataire n'est pas une alerte. C'est une ligne de journal de plus.

Bilan chiffré

Les tableaux de couches de cet article recensent 57 contrôles. Le décompte par badge est le suivant.

Élément Valeur
Couches cartographiées 7
Contrôles recensés 57
✅ en place et versionné 24
🔶 partiel ou non bloquant 13
⚪ cliqué dans une interface 12
❌ absent 8
Microservices 15
Nœuds Kubernetes sous Istio Ambient 1.30.3 6
Hôtes qui expédient leurs journaux 13
Règles ArchUnit 30
Fichiers de scénarios Karate 41
Fichiers portant une annotation d'autorisation 84
Budgets de rate limiting 9, de 20 à 300 requêtes par minute
Bases sauvegardées chaque nuit Une quinzaine, à 02:30
Rétention des sauvegardes 7 jours et 4 semaines, vers le NAS
Registre d'images Harbor v2.14.0, Trivy au seuil critique
WAF ModSecurity v3.0.8, OWASP CRS v4.0.0
Signaux unifiés dans avuru obs 5 (traces, logs, métriques, profils, erreurs), un seul moteur de stockage

Vingt-quatre contrôles sur cinquante-sept survivraient à une réinstallation complète. Douze disparaîtraient avec le run-book.

Le ratio qui compte n'est pas 49 contrôles présents sur 57. C'est 24 contrôles reproductibles sur 57.

Ce qui manque, par priorité

Je range cette liste en feuille de route plutôt qu'en confession. Elle est ordonnée par rapport entre le gain de sécurité et l'effort.

  1. MFA sur Keycloak. Un seul realm à configurer, et toute la surface d'authentification du lab remonte d'un cran.
  2. Resserrer pg_hba et activer TLS sur PostgreSQL. Deux fichiers de configuration pour mon point de défaillance unique.
  3. Faire passer SonarQube, ZAP, Semgrep et Gitleaks de « alerte » à « bloque ». Les outils sont déjà là. Il ne manque qu'un seuil et le courage de casser mes propres builds.
  4. Sauvegarde hors site chiffrée, pour tenir enfin la règle 3-2-1.
  5. Décommenter et durcir le securityContext des conteneurs, chart par chart.
  6. Créer les streams et les définitions d'alerte Graylog, pour que le tuyau vers Discord serve à autre chose qu'aux tests.
  7. NetworkPolicies sur les namespaces applicatifs, pour ne plus dépendre uniquement du mesh.
  8. SBOM signé. Syft produit déjà les inventaires, la signature manque.
  9. Sortir les mots de passe par défaut des rôles Ansible vers Ansible Vault.
  10. Scanner les serveurs MCP et introduire du red-teaming LLM, la famille la plus jeune et la moins outillée.
Les quatre premières lignes coûtent moins d'une journée chacune. C'est précisément pour ça qu'elles traînent depuis des mois.

Leçons

1. Un contrôle non versionné n'existe qu'aussi longtemps que la mémoire

Douze de mes contrôles vivent dans des interfaces web. Les règles de l'UDM Pro, le seuil CVE de Harbor, la politique inter-VLAN. Ils fonctionnent parfaitement, aujourd'hui.

Le jour où je remonte cette infrastructure, ils n'existent plus, et rien ne me le dira. Aucun test ne va échouer parce qu'une règle de pare-feu manque. Le service répondra, plus vite même, et je mettrai des semaines à comprendre pourquoi.

C'est la raison pour laquelle j'ai créé un badge distinct plutôt que de compter ces contrôles comme acquis.

2. « Déployé » et « bloquant » sont deux états différents

C'est la leçon qui m'a le plus surpris. J'aurais juré que ma chaîne de sécurité applicative bloquait. En réalité, sur les onze outils recensés dans le tableau du pipeline, trois interrompent une livraison.

Le reste envoie du signal dans un tableau de bord, et un signal que personne ne lit a exactement la même valeur qu'un outil désinstallé, avec en prime le coût CPU et l'illusion de couverture.

Le pire cas n'est pas l'outil absent. C'est l'outil présent dont on croit qu'il protège.

3. L'audit immuable est le contrôle qui m'a coûté le plus, et je ne le regrette pas

L'outbox transactionnel, la chaîne de checksums SHA-256, le trigger qui refuse les modifications, le REVOKE sur le compte applicatif, le partitionnement annuel : c'est de loin ce qui m'a demandé le plus de code.

Et c'est le seul contrôle du lab qui garde sa valeur après une compromission. Tous les autres protègent le présent. Celui-là protège le récit de ce qui s'est passé, y compris contre celui qui a pris la main.

4. Cartographier fait plus pour la sécurité qu'ajouter un outil

Je n'ai rien installé pendant cet exercice. J'ai lu des dépôts, ouvert des interfaces, compté des fichiers et attribué des badges.

Résultat : j'ai découvert que mon securityContext était commenté partout, que le mode effectif de mon WAF était une supposition, et que mes NetworkPolicies n'existaient que dans un chart. Trois trous qu'aucun scanner ne m'aurait signalés, parce qu'aucun scanner ne connaît mon intention.

L'écart entre le schéma et le code est un gisement de vulnérabilités. Il ne se lit qu'en les mettant côte à côte.


Points clés

  • Sept couches, 57 contrôles. 24 versionnés, 13 partiels, 12 cliqués à la main, 8 absents. Le décompte est plus instructif que le schéma.
  • Trois contrôles bloquent réellement une livraison : ArchUnit, Karate et Harbor. Les huit autres outils du pipeline produisent du signal.
  • Le mesh porte l'isolation du cluster, via le mTLS STRICT d'Istio Ambient et une règle explicite entre staging et production, faute de NetworkPolicies sur les namespaces applicatifs.
  • L'audit immuable est le contrôle le plus solide : outbox transactionnel, checksum SHA-256 chaîné, trigger de refus et REVOKE sur le compte applicatif.
  • La collecte est mûre, la réponse ne l'est pas. Treize hôtes expédient leurs journaux, un seul workflow de remédiation existe, et il répare le SOC lui-même.

Pour aller plus loin


Cet article a été écrit en confrontant le schéma au code, dépôt par dépôt, badge par badge. Plusieurs contrôles que je croyais verts sont ressortis oranges, et deux que je croyais oranges étaient rouges. Ils sont restés tels quels. C'est l'esprit du Forgé au lab : la carte vaut par ce qu'elle admet, pas par ce qu'elle affiche.