Aller au contenu

Architecture technique d'ILL Watch

Ce document décrit comment le code est organisé et pourquoi. Pour les spécifications fonctionnelles, voir le Cahier des charges.

Principe directeur

ILL Watch suit une Clean Architecture en couches, avec une dépendance strictement descendante : une couche ne connaît que celle du dessous.

  [CLIENTS]           CLI (Click)                 API REST (FastAPI)
                           │                              │
  [CONTROLLERS]      illwatch/cli/                   illwatch/app/api/
                           └──────────────┬──────────────┘
                                          ▼
  [SERVICES]                      illwatch/modules/
                       (logique métier pure, calculateurs,
                         collecteurs, moteur de scoring)
                                          ▼
  [DATA ACCESS]          illwatch/app/database.py · illwatch/app/models/
                                          ▼
  [PERSISTENCE]                PostgreSQL 16 · Redis 7

La règle d'or : un contrôleur d'API ne fait jamais de calcul métier ni de requête SQL complexe. Il valide les paramètres d'entrée, appelle la couche modules/, et retourne un schéma Pydantic.

La raison n'est pas esthétique. Le moteur de scoring et la machine d'état des incidents sont les deux endroits où une erreur a des conséquences opérationnelles réelles : une CVE mal priorisée n'est pas patchée, un incident mal fermé perd sa traçabilité forensique. Les garder dans des fonctions pures, sans dépendance à HTTP ni à la base, permet de les tester exhaustivement — ce que reflètent les 100 % de couverture sur scoring.py et state_machine.py.

Découpage des paquets

Chemin Rôle Dépend de
illwatch/shared/ Énumérations et utilitaires transverses rien
illwatch/app/config.py Configuration Pydantic validée au démarrage rien
illwatch/app/database.py Moteur, sessions, classe Base config
illwatch/app/models/ Modèles relationnels SQLAlchemy database, shared
illwatch/modules/ Logique métier pure par domaine models, shared
illwatch/app/api/ Contrôleurs REST modules, database
illwatch/cli/ Commandes Click modules, config
frontend/ Interface web React (ADR-016), servie par illwatch/app/web.py l'API REST et SSE uniquement

Une importation qui remonte cette liste est un défaut d'architecture, pas un raccourci.

Décisions structurantes

Asynchrone de bout en bout

La collecte est une charge I/O-bound : des dizaines de requêtes HTTP vers des API distantes dont certaines répondent en plusieurs secondes. Un modèle synchrone bloquerait un worker par flux. Tout le chemin — httpx, SQLAlchemy async, asyncpg, FastAPI — est asynchrone, ce qui permet de tenir l'objectif RNF-MEM-01 (≤ 256 Mo) avec un seul process.

Corollaire : aucun appel bloquant ne doit entrer dans la boucle d'événements. Une bibliothèque synchrone incontournable passe par asyncio.to_thread.

Isolation des collecteurs

Chaque collecteur tourne dans un worker indépendant (RSK-02). L'échec d'un flux ne doit jamais faire tomber les autres ni l'API. Un flux en échec passe en status = DEGRADED avec son erreur tracée, et le cycle suivant le réessaie — il n'est pas désactivé automatiquement.

Déduplication portée par la base

La contrainte UNIQUE (type, value) sur indicators est ce qui garantit RF-08, pas la discipline du code applicatif. Un INSERT ... ON CONFLICT DO UPDATE laisse PostgreSQL arbitrer les courses entre workers concurrents. La normalisation en amont (normalize_indicator) garantit que EVIL.COM et evil.com heurtent bien la même ligne.

Résilience réseau

Trois tentatives avec backoff exponentiel (tenacity), timeout de 15 s, respect de l'en-tête Retry-After sur HTTP 429, cache Redis pour amortir le rate limiting des API publiques (RSK-01). Les clés API sont optionnelles : ILL Watch fonctionne sans, plus lentement.

Migrations

Le schéma n'est jamais modifié à la main ni par create_all() en production. Toute évolution passe par une révision Alembic committée, alembic/env.py lisant l'URL depuis la configuration Pydantic plutôt que depuis alembic.ini — pour qu'il n'existe qu'une seule source de vérité.

Flux de données — pipeline de collecte

   Sources externes            ILL Watch                          Sorties
   ────────────────            ──────                          ───────
   NVD 2.0        ─┐
   CISA KEV       ─┤        ┌──────────────┐
   FIRST EPSS     ─┼──────► │ Collecteurs  │  worker isolé par flux
   AlienVault OTX ─┤        └──────┬───────┘
   Flux STIX/JSON ─┘               ▼
                            ┌──────────────┐
                            │Normalisation │  typage IOC, canonisation
                            └──────┬───────┘
                                   ▼
                            ┌──────────────┐
                            │Déduplication │  UNIQUE(type,value) + upsert
                            └──────┬───────┘
                                   ▼
                            ┌──────────────┐
                            │   Scoring    │  R ∈ [0,100], déterministe
                            └──────┬───────┘
                                   ▼
                            ┌──────────────┐        ┌──────────────┐
                            │  PostgreSQL  │ ─────► │ API · CLI ·  │
                            │    Redis     │        │  Dashboard   │
                            └──────────────┘        └──────────────┘

Temps réel (ADR-016)

 API (incidents, alertes)  ─┐   écriture en base          après COMMIT
 Worker (collecte, chasse) ─┴─► after_flush : événement ─► publication Redis
                                 déduit, mis en attente     canal illwatch:events
                                                                   │
                     navigateur ◄── GET /api/v1/stream (SSE) ◄─────┘
                     relit la donnée par l'API REST   filtrage par rôle, battement 15 s

Un événement annonce un changement (identifiant, niveau), jamais la donnée elle-même : l'interface la relit avec les droits de l'utilisateur. Il n'est publié qu'après le commit ; une transaction annulée n'annonce rien. Sans Redis, le bus reste local au processus (l'API voit ses propres événements, pas ceux du worker) et un avertissement est journalisé.

Modèle de données

Seize tables, créées par huit migrations (alembic/versions/). Les contraintes métier sont portées par la base (CHECK sur chaque énumération, clés étrangères, unicité), pas seulement par le code : une écriture SQL directe ne peut pas créer un état incohérent.

users ──┬──< incidents ──┬──< incident_events        (immuable : UPDATE/DELETE/TRUNCATE refusés)
        │                ├──< incident_indicators >── indicators
        │                └──< incident_cves ──────>── cves
        ├──< hunting_sessions ──< hunting_matches ──> indicators | cves
        ├──< audit_events                             (journal d'audit, ajout seul — ADR-011)
        └──< refresh_tokens                           (sessions révocables — ADR-012)

threat_feeds ──< indicators ──< indicator_sources >── threat_feeds   (provenance, ADR-006)

cves ──< cve_priority_history          cves ──< cve_alerts ──> incidents.source_alert_id
collector_state                        (curseurs NVD, ligne de base des alertes, cadences)

Index critiques : uq_indicator_type_value (déduplication), idx_indicators_last_seen (expiration), idx_cves_risk_score et idx_cves_priority (tri et filtres du moteur CVE), idx_incident_events_timeline (chronologie), idx_incidents_status_severity (tableau de bord).

Sécurité

Mesure Mise en œuvre
Aucun secret dans le code ni en base Configuration Pydantic depuis l'environnement ; gabarits d'URL et en-têtes ; masquage dans erreurs et journaux
SSRF Liste blanche d'adresses publiques, DNS vérifié avant chaque requête, redirections revalidées (httpx) ou refusées (taxii2-client) — threat_feeds/fetcher.py, taxii.py
Réponses hostiles Taille plafonnée lue en flux, délai par requête, backoff, 429 respecté
Authentification JWT HS256 de 15 min avec kid (rotation de clé sans déconnexion), Argon2id, rôle relu en base ; jeton de rafraîchissement opaque, rotatif, lignée révoquée au rejeu ; 429 après 5 échecs compte × IP, 50 par compte, 20 par IP
Moindre privilège en base Rôle applicatif sans droit de structure, SELECT/INSERT seulement sur les tables en ajout seul ; migrations par le rôle propriétaire (app/db_roles.py, ADR-012)
Validation stricte des entrées Pydantic v2 (extra="forbid") sur toutes les frontières d'API
Pas d'injection SQL Aucune requête construite par concaténation ; ORM ou requêtes paramétrées
Intégrité de l'audit Chronologie d'incident et journal d'audit en ajout seul : ORM + déclencheurs PostgreSQL refusant UPDATE, DELETE et TRUNCATE (ADR-011)
Traçabilité audit_events : connexions, refus d'accès, administration, exports, chasse ; X-Request-ID relie réponse, accès et audit (app/middleware.py, foundation/audit.py)
Couche HTTP nosniff, X-Frame-Options, CSP default-src 'none' et no-store sur l'API, HSTS en production, 500 JSON sans détail interne
Exploitation /health (vivacité) et /ready (base + schéma), sauvegarde vérifiée et restauration, entretien quotidien (docs/OPERATIONS.md)
Transport et déploiement Caddy (TLS, HTTP/2-3) seul exposé ; migrate éphémère avec le rôle propriétaire ; API et worker en rôle applicatif (docker-compose.prod.yml, ADR-013)
Injection CSV Cellules exportées neutralisées (dashboard/service.py)
Conteneur non privilégié Utilisateur illwatch UID 10001 ; .dockerignore excluant .env et .git
Dépendances surveillées et figées constraints.txt partagé par la CI, l'image et le poste ; Renovate ; SAST, secrets et dépendances analysés à chaque pipeline

Ce qui n'est délibérément pas fait en v1.0

  • Pas de microservices. Le monolithe modulaire tient jusqu'à 100 000 événements/jour. Découper avant d'en avoir besoin coûterait la simplicité de déploiement, qui est un argument produit.
  • Pas d'Elasticsearch. PostgreSQL avec des index adaptés couvre les besoins de recherche de la v1.0 (P95 de 8 ms sur 30 000 CVE). C'est la lourdeur des alternatives qu'ILL Watch évite.
  • ~~Pas d'IHM web~~ (levé le 08/10/2026 par l'ADR-016) : la v1.0 n'avait qu'une API et une CLI ; l'interface web React + TypeScript (temps réel par SSE) est en cours dans frontend/.
  • Pas de moteur Sigma complet. Les règles de chasse portent sur des observables, pas sur des journaux structurés : six règles déterministes suffisent (ADR-009).
  • Pas de facteur CWE dans le scoring. Voir ADR-001.