Entraîner un NER sécurité : du corpus annoté au modèle en production
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 appelerextract()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 :
