#17 — Anti-fraude
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/fraud_detection.py
Dépendances: #3 rgz-portal · #6 rgz-radius · #8 rgz-ids
Description
Le module Anti-fraude implémente la détection multi-vecteurs des comportements malveillants sur le réseau ACCESS. Il traite cinq catégories de signaux : l'usurpation d'adresse MAC (MAC spoofing), la géolocalisation simultanée impossible (même MAC sur deux points d'accès distants), l'enregistrement massif de comptes depuis une même MAC (bots), les attaques par force brute sur les OTP, et le tunneling DNS (contournement payant via requêtes DNS exfiltrées).
Philosophie fondamentale : conformément à la décision DG sur l'identité abonné, ce module NE BLOQUE JAMAIS un abonné uniquement sur sa MAC. La MAC est un indicateur réseau technique, pas une identité. Les alertes de fraude servent à informer le NOC et déclencher des investigations, pas à suspendre automatiquement des comptes. Seule une action humaine (ou une règle explicitement validée) peut escalader une alerte vers une suspension de compte.
L'intégration avec Suricata (#8) est unidirectionnelle : Suricata génère des événements via son API EVE-JSON, le service fraud_detection les consomme pour enrichir son contexte (notamment pour la détection de DNS tunneling qui requiert une analyse de trafic réseau profonde). Les autres signaux (MAC spoofing, géographie) sont détectés directement via les données RADIUS et les tables DB.
Toutes les alertes sont persistées en base de données avec leur contexte complet (JSONB details), leur sévérité et leur statut (open / dismissed / escalated). Le NOC peut accuser réception, annoter et clore les alertes depuis le dashboard NOC (#52). Les alertes de sévérité CRITICAL déclenchent automatiquement une notification SMS au NOC via le template engine (#61).
Architecture Interne
Signaux de Fraude et Sévérités
| Signal | Code | Sévérité | Déclencheur | Action automatique |
|---|---|---|---|---|
| Usurpation MAC | MAC_SPOOFING | HIGH | Même MAC → 2 subscriber_ids différents | Alerte NOC |
| Géographie impossible | SIMULTANEOUS_GEOGRAPHY | CRITICAL | Même MAC sur 2 NAS en même temps | Alerte NOC + SMS |
| Enregistrement massif | MASS_REGISTRATION | HIGH | Même MAC → >10 subscriber_ids en 24h | Alerte NOC + blocage IP temporaire |
| Force brute OTP | BRUTE_FORCE_OTP | MEDIUM | >3 tentatives OTP en 1 minute | Blocage MSISDN 15min |
| Tunneling DNS | DNS_TUNNEL | HIGH | Requêtes DNS anormales (Suricata) | Alerte NOC |
Flux de Détection
Événement entrant (auth RADIUS, tentative OTP, event Suricata)
↓
FraudDetectionService.analyze_event(event_type, context)
↓
┌─────────────────────────────────────────────────────────────────┐
│ SIGNAL 1: MAC_SPOOFING │
│ - Lors d'une authentification RADIUS (ou portail) │
│ - Chercher subscriber_ids associés à cette MAC en DB │
│ - Si MAC associée à un subscriber_id différent du demandeur │
│ → Créer FraudAlert(MAC_SPOOFING, HIGH) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ SIGNAL 2: SIMULTANEOUS_GEOGRAPHY │
│ - Lors d'un Accounting-Start RADIUS │
│ - Chercher sessions actives (is_active=True) avec même MAC │
│ - Si session active sur un NAS-ID différent ET même timestamp │
│ → Créer FraudAlert(SIMULTANEOUS_GEOGRAPHY, CRITICAL) │
│ → Notif SMS NOC immédiate via #61 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ SIGNAL 3: MASS_REGISTRATION │
│ - Lors de chaque nouvelle inscription │
│ - Compter subscriber_ids distincts avec cette MAC (24h gliss.) │
│ - Redis: SADD rgz:fraud:mac:{mac} {subscriber_id} TTL=86400s │
│ - Si SCARD > 10 → FraudAlert(MASS_REGISTRATION, HIGH) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ SIGNAL 4: BRUTE_FORCE_OTP │
│ - Lors de chaque tentative OTP échouée │
│ - INCR rgz:otp:attempts:{msisdn} + EXPIRE 60s │
│ - Si compteur > 3 → FraudAlert(BRUTE_FORCE_OTP, MEDIUM) │
│ → Bloquer MSISDN 15min: SET rgz:otp:blocked:{msisdn} 1 EX 900 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ SIGNAL 5: DNS_TUNNEL │
│ - Consommation EVE-JSON Suricata (alert.signature contient │
│ "DNS" ou "TUNNEL") │
│ - Enrichissement avec subscriber_id depuis sessions RADIUS │
│ → FraudAlert(DNS_TUNNEL, HIGH) │
└─────────────────────────────────────────────────────────────────┘
↓
FraudAlert persistée en DB (LL#26: DB first)
Redis cache invalidé si nécessaire
Notification NOC si sévérité >= HIGHService Principal (app/services/fraud_detection.py)
from uuid import UUID
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
import redis
class FraudDetectionService:
def __init__(self, db: Session, redis_client: redis.Redis):
self.db = db
self.redis = redis_client
async def check_mac_spoofing(
self,
mac_address: str,
subscriber_id: UUID,
nas_id: str
) -> FraudAlert | None:
"""
Vérifie si la MAC est déjà associée à un autre subscriber_id.
LL#26: crée l'alerte en DB avant de mettre à jour le cache.
"""
async def check_simultaneous_geography(
self,
mac_address: str,
nas_id: str,
session_start: datetime
) -> FraudAlert | None:
"""
Vérifie sessions actives avec même MAC sur NAS différent.
Sévérité CRITICAL si même timestamp (impossible physiquement).
"""
async def check_mass_registration(
self,
mac_address: str,
new_subscriber_id: UUID
) -> FraudAlert | None:
"""
SADD rgz:fraud:mac:{mac} {subscriber_id}
Si SCARD > 10 → alerte MASS_REGISTRATION HIGH.
"""
async def check_brute_force_otp(
self,
msisdn: str,
ip_address: str
) -> bool:
"""
Retourne True si MSISDN doit être bloqué.
INCR + EXPIRE 60s. Si >3 → SET blocked EX 900.
"""
async def process_suricata_event(
self,
eve_event: dict
) -> FraudAlert | None:
"""
Traite un événement EVE-JSON Suricata.
Enrichit avec subscriber_id depuis radius_sessions.
"""
async def create_alert(
self,
alert_type: str,
severity: str,
subscriber_id: UUID | None,
mac_address: str,
nas_id: str,
details: dict
) -> FraudAlert:
"""Persiste alerte en DB (LL#26) et notifie NOC si HIGH/CRITICAL."""Configuration
Variables d'environnement
# Anti-fraude
FRAUD_MASS_REGISTRATION_THRESHOLD=10 # MACs → subscriber_ids en 24h
FRAUD_OTP_BRUTE_FORCE_MAX_ATTEMPTS=3 # Tentatives avant blocage
FRAUD_OTP_BRUTE_FORCE_WINDOW_SECONDS=60
FRAUD_OTP_BLOCK_DURATION_SECONDS=900 # 15 minutes
# Suricata EVE-JSON
SURICATA_EVE_LOG_PATH=/var/log/suricata/eve.json
SURICATA_POLL_INTERVAL_SECONDS=10
# NOC Notifications (via #61 SMS)
FRAUD_NOC_PHONE=+22901000000
FRAUD_ALERT_SEVERITY_SMS_THRESHOLD=HIGH # HIGH et CRITICAL → SMS NOCEndpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/fraud/alerts | — | 200 {items, total, page, pages} | Admin JWT | Liste alertes |
| GET | /api/v1/fraud/alerts/{id} | — | 200 {alert detail} | Admin JWT | Détail alerte |
| POST | /api/v1/fraud/alerts/{id}/acknowledge | {note?} | 200 | Admin JWT | Accuser réception |
| POST | /api/v1/fraud/alerts/{id}/dismiss | {reason} | 200 | Admin JWT | Clôturer |
| POST | /api/v1/fraud/alerts/{id}/escalate | {action} | 200 | Admin JWT | Escalader P0/P1 |
| GET | /api/v1/fraud/report?from=&to= | — | 200 | Admin JWT | Rapport fraude |
| GET | /api/v1/fraud/stats | — | 200 | Admin JWT | Stats agrégées |
GET /api/v1/fraud/alerts
Query params: ?severity=HIGH&status=open&page=1&size=20
Response 200:
{
"items": [
{
"id": "880e8400-e29b-41d4-a716-446655440020",
"alert_type": "MAC_SPOOFING",
"severity": "HIGH",
"subscriber_id": "990e8400-e29b-41d4-a716-446655440021",
"mac_address": "AA:BB:CC:DD:EE:FF",
"nas_id": "access_kossou",
"details": {
"conflicting_subscriber_id": "aa0e8400-e29b-41d4-a716-446655440022",
"conflicting_subscriber_ref": "RGZ-0197979964",
"first_seen": "2026-02-20T08:15:00Z"
},
"status": "open",
"acknowledged_at": null,
"created_at": "2026-02-21T09:30:00Z"
}
],
"total": 47,
"page": 1,
"pages": 3
}GET /api/v1/fraud/report?from=2026-02-01&to=2026-02-21
Response 200:
{
"period": {"from": "2026-02-01T00:00:00Z", "to": "2026-02-21T23:59:59Z"},
"totals": {
"alerts_total": 127,
"alerts_open": 12,
"alerts_dismissed": 108,
"alerts_escalated": 7
},
"by_type": {
"MAC_SPOOFING": 45,
"SIMULTANEOUS_GEOGRAPHY": 3,
"MASS_REGISTRATION": 8,
"BRUTE_FORCE_OTP": 64,
"DNS_TUNNEL": 7
},
"by_severity": {
"LOW": 0,
"MEDIUM": 64,
"HIGH": 60,
"CRITICAL": 3
},
"top_offending_macs": [
{"mac": "AA:BB:CC:DD:EE:FF", "alerts": 12, "subscriber_ids": 3}
]
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:fraud:mac:{mac_address} | Set | 86400s | subscriber_ids vus avec cette MAC (24h glissant) |
rgz:otp:attempts:{msisdn} | String (INCR) | 60s | Compteur tentatives OTP échouées |
rgz:otp:blocked:{msisdn} | String | 900s | MSISDN bloqué brute-force OTP |
rgz:fraud:alerts:recent | List | 300s | Dernières alertes HIGH/CRITICAL (pour NOC dashboard) |
Note LL#26 : L'alerte est toujours créée en DB en premier, puis le cache Redis est mis à jour. En cas d'erreur Redis, l'alerte reste persistée.
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-01 | IDOR : les endpoints /api/v1/fraud/* requièrent le rôle admin — aucun revendeur ne voit les alertes d'autres revendeurs |
| LL#26 | DB write first : alerte créée en DB avant toute action Redis ou notification |
| Philosophie | NE JAMAIS suspendre un compte abonné automatiquement sur un signal MAC — humain requis |
| hmac.compare_digest | Si intégration webhook Suricata via HTTP, vérifier signature avec compare_digest |
# Vérification ownership SEC-01 — admin uniquement
@router.get("/api/v1/fraud/alerts")
async def list_fraud_alerts(
current_user: User = Depends(require_admin), # 403 si non-admin
db: Session = Depends(get_db),
page: int = 1,
size: int = 20
):
alerts = db.query(FraudAlert)\
.order_by(FraudAlert.created_at.desc())\
.offset((page - 1) * size)\
.limit(size)\
.all()
total = db.query(FraudAlert).count()
return {"items": alerts, "total": total, "page": page, "pages": ceil(total / size)}Commandes Utiles
# Lister alertes ouvertes de sévérité HIGH
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://api-rgz.duckdns.org/api/v1/fraud/alerts?severity=HIGH&status=open"
# Accuser réception alerte
curl -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note": "En cours d investigation"}' \
"https://api-rgz.duckdns.org/api/v1/fraud/alerts/880e8400-e29b-41d4-a716-446655440020/acknowledge"
# Vérifier si un MSISDN est bloqué OTP
docker exec rgz-redis redis-cli GET "rgz:otp:blocked:+22901979964"
# Vérifier nb subscriber_ids vus pour une MAC
docker exec rgz-redis redis-cli SCARD "rgz:fraud:mac:AA:BB:CC:DD:EE:FF"
# Voir les membres (subscriber_ids)
docker exec rgz-redis redis-cli SMEMBERS "rgz:fraud:mac:AA:BB:CC:DD:EE:FF"
# Rapport fraude mensuel
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://api-rgz.duckdns.org/api/v1/fraud/report?from=2026-02-01&to=2026-02-28"
# Logs anti-fraude
docker logs rgz-api --tail=100 | grep "FRAUD\|fraud"Implémentation TODO
- [ ] Modèle SQLAlchemy
FraudAlertavec CHECK constraint severityIN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL') - [ ] Migration Alembic table
fraud_alerts - [ ]
FraudDetectionService.check_mac_spoofing()(appelé depuis endpoint auth RADIUS) - [ ]
FraudDetectionService.check_simultaneous_geography()(appelé sur Accounting-Start) - [ ]
FraudDetectionService.check_mass_registration()(appelé sur inscription #11) - [ ]
FraudDetectionService.check_brute_force_otp()(appelé depuis endpoint OTP #12) - [ ]
FraudDetectionService.process_suricata_event()(consommateur EVE-JSON) - [ ] Endpoint
GET /api/v1/fraud/alertsavec pagination - [ ] Endpoint
POST /api/v1/fraud/alerts/{id}/acknowledge - [ ] Endpoint
POST /api/v1/fraud/alerts/{id}/dismiss - [ ] Endpoint
POST /api/v1/fraud/alerts/{id}/escalate - [ ] Endpoint
GET /api/v1/fraud/report - [ ] Intégration notification SMS NOC (via #61) pour alertes HIGH/CRITICAL
- [ ] Tâche Celery poll EVE-JSON Suricata (every 10s)
- [ ] Tests unitaires chaque signal de fraude
- [ ] Tests SEC-01 (accès non-admin → 403)
- [ ] Tests intégration Suricata EVE-JSON mock
Dernière mise à jour: 2026-02-21