NOPE LinkedIn

Catégories:
Blog

Entraîner un NER sécurité : du corpus annoté au modèle en production

Rubrique: Blog Tag: NLP Tag: NER Tag: spaCy Tag: Fine-tuning Tag: Anonymisation Tag: Logs Tag: Cybersécurité Tag: MLOps Tag: Dataset

Entraîner un NER sécurité : du corpus annoté au modèle en production

Les entités de sécurité que spaCy standard ne détecte pas ne sont pas impossibles à apprendre — elles sont simplement absentes de ses données d’entraînement. La solution n’est pas de remplacer spaCy par un LLM lourd, mais d’entraîner le composant NER de spaCy sur des exemples spécifiques au domaine.

Ce chemin — annotation LLM-assistée → fine-tuning spaCy → modèle production léger — est à la fois robuste et déployable sans GPU.


Les 12 labels du NER sécurité

Le choix des labels est le premier travail de modélisation. Trop large, le modèle se noie dans des cas ambigus. Trop étroit, il manque des entités critiques. Après analyse des logs OPNsense, Wazuh, Suricata et syslog, 12 labels couvrent l’essentiel des entités sensibles d’une infrastructure réseau :

Label Exemples Pourquoi sensible
IP_ADDRESS 192.168.10.50, 10.0.0.1 Cartographie réseau interne
IP_SUBNET 10.10.42.0/24, 192.168.0.0/16 Topologie réseau interne
HOSTNAME srv-dc01.corp.local, fw-edge-01 Nommage infrastructure
PORT_NUMBER port 52341, 443/tcp Ports ouverts, services exposés
INTERFACE em0, WAN, LAN, eth0 Topologie physique
MAC_ADDRESS 00:1A:2B:3C:4D:5E Identification matérielle
SERVICE_ACCOUNT svc_splunk_admin, svc_crowdsec Comptes à droits élevés
CVE CVE-2024-12345 Vulnérabilités liées à des assets
HASH sha256:a1b2c3... Empreintes de fichiers/artefacts
FIREWALL_RULE ALLOW_RDP_FROM_VPN_192.168.10.0 Politique de sécurité interne
VPN_USER jdoe@corp, vpn-user-42 Utilisateurs VPN avec accès réseau
SNMP_COMMUNITY public, corp-monitoring Credentials SNMP

Ces labels sont synchronisés dans le code entre la couche NER et le coordinateur — une seule source de vérité :

# agents/ner_extractor.py
NER_LABELS = {
    "IP_ADDRESS", "IP_SUBNET", "HOSTNAME", "PORT_NUMBER", "INTERFACE",
    "MAC_ADDRESS", "SERVICE_ACCOUNT", "CVE", "HASH", "FIREWALL_RULE",
    "VPN_USER", "SNMP_COMMUNITY",
}

Pourquoi spaCy fine-tuné plutôt qu’un LLM en production

La question se pose naturellement : si un LLM peut annoter des logs, pourquoi ne pas l’utiliser directement en inférence ?

spaCy NER fine-tuné LLM (Phi-3.5-mini) LLM (Claude API)
Taille modèle ~50 Mo ~2-4 Go API externe
Inférence <5 ms/doc ~200 ms/doc ~1-3 s/appel
RAM requise ~500 Mo ~6 Go (GPU)
Intégration anonyfiles Native Wrapper requis Wrapper requis
Données vers l’extérieur Aucune Aucune (local) Oui
Coût passage à l’échelle Fixe Fixe (hardware) Variable (tokens)

L’argument décisif dans un contexte de logs sensibles : spaCy tourne localement, ne sort aucune donnée. Un log contenant des comptes de service et des IPs internes ne doit pas transiter vers une API externe pour être annoté.

De plus, l’inférence à <5 ms permet un traitement inline — le NER s’exécute dans le pipeline de traitement des logs, en temps réel, sans overhead perceptible.


Le goulot d’étranglement : constituer le corpus annoté

Le fine-tuning spaCy nécessite des exemples annotés — des logs avec les entités marquées et leur type. L’annotation manuelle est précise mais coûteuse : compter 1-2 minutes par exemple, soit plusieurs semaines pour 5 000 exemples.

L’approche LLM-assistée résout ce problème :

graph LR
    A[Logs bruts réels\nOpenSSH, syslog, OPNsense] --> B[LLM annotateur\nClaude / Phi-3.5-mini]
    B --> C[Corpus annoté\nformat spaCy JSONL]
    C --> D[Fine-tuning\nspaCy NER]
    D --> E[Modèle AnonyNER\n~50 Mo]
    E --> F[Inférence production\n< 5 ms/doc]

Le LLM ne fait pas d’inférence en production — il crée les données d’entraînement. Cette distinction est fondamentale : le LLM est un outil de labelling, pas un composant de l’architecture finale.


Format du corpus d’entraînement

Chaque exemple est stocké au format JSONL avec trois champs :

{
  "text": "Failed password for svc_splunk_admin from 192.168.10.50 port 52341 ssh2",
  "entities": [
    [19, 35, "SERVICE_ACCOUNT"],
    [41, 54, "IP_ADDRESS"],
    [60, 65, "PORT_NUMBER"]
  ],
  "spacy_format": true
}

Les entités sont définies par leurs offsets de caractères [start, end, label] — le format natif de spaCy pour le fine-tuning NER. La validation des offsets (cohérence avec le texte) est effectuée avant l’entraînement pour éviter les erreurs silencieuses.

Taille actuelle du corpus :

Source Exemples Langue Contenu
anonyner_train.jsonl 1 018 Mixte Extractions réelles annotées
anonyner_synthetic_v2.jsonl 550 Français Générés par template
anonyner_synthetic_en.jsonl 924 Anglais Expansion couverture
anonyner_extractions_v2.jsonl 179 Mixte Validation récente
anonyner_combined_v2.jsonl 1 997 Mixte Dataset combiné (dédupliqué)

Le dataset combiné net après déduplication compte ~2 000 exemples annotés — suffisant pour un NER à 12 labels sur un domaine contraint, mais en dessous de l’idéal (5 000+ recommandé pour une bonne généralisation sur des logs atypiques).


Architecture du composant NER spaCy

spaCy utilise une architecture NER à base de transitions (transition-based NER), distincte des transformers HuggingFace. Le fine-tuning ne réentraîne que le composant NER — le tokenizer, le tagger et le parser restent intacts.

# Exemple d'entraînement (simplifié depuis train_anonyner.py)
TRAIN_DATA = [
    (
        "srv-dc01.corp.local a redémarré",
        {"entities": [(0, 18, "HOSTNAME")]}
    ),
    (
        "Règle ALLOW_RDP_FROM_VPN bloquée par le firewall",
        {"entities": [(6, 25, "FIREWALL_RULE")]}
    ),
    (
        "Compte svc_splunk_admin actif sur em0",
        {"entities": [(8, 24, "SERVICE_ACCOUNT"), (35, 38, "INTERFACE")]}
    ),
]

La config spaCy pour le fine-tuning NER seul (les autres composants sont gelés) :

[training]
frozen_components = ["tok2vec", "tagger", "parser", "senter"]

[training.optimizer]
@optimizers = "Adam.v1"
learn_rate = 0.001

[nlp]
pipeline = ["tok2vec", "ner"]

L’interface de production : NERExtractor

En production, le modèle est exposé via un extracteur léger avec chargement lazy et interface standardisée :

from agents.ner_extractor import NERExtractor

ner = NERExtractor()
entities = ner.extract(
    "Block IP 10.0.0.1 on WAN interface eth0 — rule DENY_ALL_INBOUND"
)
# Retourne :
# {
#   "IP_ADDRESS":    ["10.0.0.1"],
#   "INTERFACE":    ["WAN", "eth0"],
#   "FIREWALL_RULE": ["DENY_ALL_INBOUND"],
#   "IP_SUBNET":    [],
#   "HOSTNAME":     [],
#   "PORT_NUMBER":  [],
#   ... (toutes les clés, listes vides si absent)
# }

Design explicite : le dict retourné contient toujours toutes les clés NER_LABELS, avec des listes vides si l’entité est absente. Cela simplifie le code en aval — pas de vérification if key in result à l’usage.

Trois propriétés clés de l’extracteur :

  • Chargement lazy — le modèle n’est chargé qu’au premier appel extract()
  • Thread-safe — spaCy nlp() est ré-entrant, plusieurs threads peuvent appeler extract() simultanément
  • Graceful degradation — si le modèle est absent, retourne des listes vides sans exception (log warning)

Double usage : anonymisation et extraction de paramètres

Le même modèle NER sécurité sert deux objectifs dans l’architecture :

graph TD
    A[Log / requête utilisateur] --> B[AnonyNER\nExtraction entités]
    B --> C1[Anonymisation\nanonyfiles]
    B --> C2[Extraction paramètres\nagents OPNsense / Wazuh]

Pour l’anonymisation : les entités extraites sont soumises au moteur anonyfiles pour remplacement selon la stratégie configurée.

Pour les agents cyber : les mêmes entités servent à construire les appels de fonction.

Requête : "bloque tout le trafic du réseau 10.42.0.0 vers le port 443"

NER extrait :
  IP_SUBNET  → 10.42.0.0
  PORT_NUMBER → 443

→ paramètres directs pour firewall_alias_add_item(subnet="10.42.0.0/24", port=443)

Un seul modèle, deux valeurs : réduction de la surface de maintenance, cohérence des entités reconnues entre les deux contextes d’usage.


Limites actuelles et prochaines étapes

Taille du corpus : ~2 000 exemples est fonctionnel mais limite la généralisation sur des logs de systèmes non couverts (firewalls exotiques, applications métier avec conventions de nommage atypiques). Objectif : 5 000+ exemples en incluant des logs Wazuh et Suricata annotés.

Entités contextuelles : les regex couvrent bien les formats fixes (CVE-YYYY-NNNNN, IPs RFC1918, comptes svc_*). Les entités contextuelles — corp-monitoring comme community SNMP vs label générique — nécessitent le NER fine-tuné pour être distinguées du contexte. C’est là que le corpus d’entraînement fait la différence.

Métriques de validation : le F1 par label sur un jeu de test holdout n’est pas encore mesuré systématiquement. C’est la prochaine étape avant déploiement en production.


Ressources :