Skip to content

#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

SignalCodeSévéritéDéclencheurAction automatique
Usurpation MACMAC_SPOOFINGHIGHMême MAC → 2 subscriber_ids différentsAlerte NOC
Géographie impossibleSIMULTANEOUS_GEOGRAPHYCRITICALMême MAC sur 2 NAS en même tempsAlerte NOC + SMS
Enregistrement massifMASS_REGISTRATIONHIGHMême MAC → >10 subscriber_ids en 24hAlerte NOC + blocage IP temporaire
Force brute OTPBRUTE_FORCE_OTPMEDIUM>3 tentatives OTP en 1 minuteBlocage MSISDN 15min
Tunneling DNSDNS_TUNNELHIGHRequê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é >= HIGH

Service Principal (app/services/fraud_detection.py)

python
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

bash
# 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 NOC

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/fraud/alerts200 {items, total, page, pages}Admin JWTListe alertes
GET/api/v1/fraud/alerts/{id}200 {alert detail}Admin JWTDétail alerte
POST/api/v1/fraud/alerts/{id}/acknowledge{note?}200Admin JWTAccuser réception
POST/api/v1/fraud/alerts/{id}/dismiss{reason}200Admin JWTClôturer
POST/api/v1/fraud/alerts/{id}/escalate{action}200Admin JWTEscalader P0/P1
GET/api/v1/fraud/report?from=&to=200Admin JWTRapport fraude
GET/api/v1/fraud/stats200Admin JWTStats agrégées

GET /api/v1/fraud/alerts

Query params: ?severity=HIGH&status=open&page=1&size=20

Response 200:

json
{
  "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:

json
{
  "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éTypeTTLUsage
rgz:fraud:mac:{mac_address}Set86400ssubscriber_ids vus avec cette MAC (24h glissant)
rgz:otp:attempts:{msisdn}String (INCR)60sCompteur tentatives OTP échouées
rgz:otp:blocked:{msisdn}String900sMSISDN bloqué brute-force OTP
rgz:fraud:alerts:recentList300sDerniè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ègleImplémentation
SEC-01IDOR : les endpoints /api/v1/fraud/* requièrent le rôle admin — aucun revendeur ne voit les alertes d'autres revendeurs
LL#26DB write first : alerte créée en DB avant toute action Redis ou notification
PhilosophieNE JAMAIS suspendre un compte abonné automatiquement sur un signal MAC — humain requis
hmac.compare_digestSi intégration webhook Suricata via HTTP, vérifier signature avec compare_digest
python
# 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

bash
# 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 FraudAlert avec CHECK constraint severity IN ('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/alerts avec 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

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone