Aller au contenu
VDRVOZit Detection & Response

Guide développeur

Vue d'ensemble

xdr/
├── agent/                 agent Vigie (Python 3.9+, bibliothèque standard) — voir agent/README.md
├── public/                racine web : index.php (contrôleur frontal), assets/
├── src/
│   ├── Core/              Config, Database (PDO), Router, View, Http (en-têtes), Net (IP/CIDR), Fmt, Req, Icon, Markdown
│   ├── Security/          Auth (sessions, rôles, connexion 2 étapes), Totp, Crypto (sodium), Audit (chaîne SHA-256)
│   ├── Domain/            Scenarios (catalogue MITRE/ISO), Hosts, Decisions, Incidents, Posture
│   ├── Api/AgentApi.php   protocole agent v1
│   └── Controller/        Site, Login, Console, HostsCtl, IncidentsCtl, AdminCtl, Platform, Web
├── views/                 layouts/ (site, console, bare, print), site/, console/, docs/, partials/
├── database/schema.sql    schéma MariaDB (idempotent)
├── bin/                   install.php, vdr (CLI)
├── docs/                  cette documentation (rendue sur /aide)
├── tests/                 integration.py (agent réel ↔ console réelle), fixture.php
└── deploy/                vhosts Apache, cron, archives de l'agent

PHP 8.2, MariaDB 10.11, aucune dépendance Composer. Le code de l'agent et celui de la console partagent un seul contrat : le protocole v1 (voir « API agent »).

Installation de développement

php bin/install.php                 # config/config.php, storage/app.key, schéma
php bin/vdr user:add-super moi@exemple.fr "Moi"
php -S 127.0.0.1:8471 -t public public/index.php

En HTTP local, mettre security.cookie_secure à false (le cookie __Host- exige HTTPS).

Conventions

  • Multi-tenant : toute requête métier filtre sur tenant_id = Auth::tenantId(). Les fonctions find(tenantId, id) des classes Domain refusent un identifiant d'une autre organisation. Ne jamais lire un identifiant de la requête sans ce filtre.
  • Droits : Auth::requireTenant('perm') en tête de chaque action ; la matrice est dans Auth::PERMS.
  • Formulaires : tout POST de la console appelle Auth::csrfCheck().
  • Affichage : toujours View::e(). Les données d'agent (messages, utilisateurs) sont hostiles par principe (S5).
  • SQL : requêtes préparées uniquement (Database::run($sql, $params)). Les seules interpolations sont des listes de ? ou des noms de colonnes en liste blanche.
  • CSP : default-src 'self' sans unsafe-inline. Pas d'attribut style=, pas de <script> en ligne, pas de javascript:. Comportements via attributs data-* dans public/assets/js/console.js. Les graphiques sont en SVG (attributs de géométrie).
  • Audit : toute action d'un compte qui modifie quelque chose appelle Audit::log(action, détail).
  • Messages : français, sans jargon inutile.

Comptes et organisations

  • users : un compte par adresse e-mail. users.role = super_admin (plateforme) ou member.
  • memberships (user_id, tenant_id, role) : appartenance et rôle (admin, analyst, viewer) par organisation.
  • L'organisation consultée est sessions.tenant_ctx. Auth::session() la revalide à chaque requête via Auth::access() (membre ou super_admin, organisation active, IP dans tenants.admin_cidrs) et la remet à NULL sinon.
  • Auth::role() donne le rôle effectif dans l'organisation consultée ; Auth::can() s'appuie dessus. Auth::requireTenant() vérifie l'organisation avant le droit (sans organisation, il n'y a pas de rôle).
  • Auth::tenants() : organisations accessibles (sélecteur). Changement : POST /console/contexte → Auth::setTenantContext(), tracé dans le journal de l'organisation ouverte.

Traitements de fond (bin/vdr tick, chaque minute)

  1. Domain\Intel::enrich() : pays (table geoip, base db-ip Lite) et réputation (ip_reputation) des alertes enriched = 0. Plages stockées sur 16 octets (Net::bin16, IPv4 en ::ffff:a.b.c.d) pour des comparaisons homogènes.
  2. Domain\Correlator::run() : alertes enrichies depuis le curseur settings.correlate.last_id ; produit des alertes d'époque 0 (hosts.console_seq). Ne repasse jamais sur ses propres alertes.
  3. Domain\Notifier::queueAlerts() / queueMute() → table notifications (file), puis sendPending() (reprises 1/5/15/60 min). Curseur settings.notify.last_alert_id. Les deux curseurs s'arrêtent avant la première alerte non enrichie : l'ordre est garanti.

Verrou storage/cache/tick.lock : une seule instance à la fois. Premier passage : les curseurs se placent sur la dernière alerte (pas de rattrapage de l'historique).

Fournisseurs SMS/push : Notifier::viaProvider() et Notifier::render() (modèles {{var}}, {{var|json}}, {{var|url}}). Webhooks : Notifier::unsafeUrl() refuse les destinations non publiques (SSRF). Requêtes sortantes : Core\HttpClient (TLS vérifié, pas de redirection).

Migrations

database/schema.sql crée le schéma initial (version 1) ; database/migrations/NNN_nom.sql sont appliquées une fois, dans l'ordre, par php bin/install.php (table schema_version). Sauvegarder la base avant (mysqldump).

Sessions et authentification

  • Cookie __Host-vdr_sid (32 octets aléatoires, HttpOnly, Secure, SameSite=Strict) ; la table sessions ne stocke que son SHA-256.
  • Étape totp (5 min) après le mot de passe, puis nouvelle session ok (anti-fixation).
  • Totp::verify() renvoie le pas accepté, mémorisé dans users.totp_last_step : un code ne sert qu'une fois.
  • Secret TOTP chiffré (Crypto::encrypt, clé storage/app.key). Pendant l'activation, le secret proposé voyage chiffré dans un cookie lié au jeton d'activation.

Journal d'audit

hash = SHA-256(prev_hash ␟ ts ␟ tenant ␟ user_id ␟ username ␟ action ␟ detail ␟ ip). La tête de chaîne est dans audit_head (verrou FOR UPDATE). Audit::verify() recalcule tout ; Posture::auditStatus() met le résultat en cache 5 min.

API agent

Voir « API agent (protocole v1) ». Points d'implémentation :

  • Idempotence : INSERT IGNORE sur UNIQUE(host_id, epoch, seq).
  • epoch : incrémentée par la rotation de jeton « agent réinstallé ». Un seq connu reçu avec un autre horodatage déclenche l'événement seq_reinitialise.
  • Bans partagés : AgentApi::maybeShare(), une décision par IP et par 24 h, exclude_host_id = serveur d'origine.
  • Débit : table api_rate (par serveur et par minute), 429 au-delà de agent.rate_per_minute.

Tests

cd agent && python3 -m unittest discover -s tests      # agent : 51 tests
python3 -I tests/integration.py                         # bout en bout, depuis la racine du projet

Le test d'intégration lance un serveur PHP local (port 8471), crée l'organisation test-integration, rejoue une attaque sur l'agent réel et vérifie réception, idempotence, partage des bans, décisions, levée, révocation et garde-fous de l'API. Il supprime l'organisation à la fin.

Ajouter un scénario

  1. Côté agent : produire l'alerte (Alert(ts, "mon_scenario", gravité, message, ip=…)).
  2. Côté console : ajouter l'entrée dans Domain/Scenarios::ALL (libellé, famille, MITRE, ISO, explication, conduite à tenir). Un scénario inconnu s'affiche quand même avec son nom brut.
  3. S'il doit être partagé entre serveurs : l'ajouter à agent.share_scenarios dans la configuration.
  4. Documenter dans le guide utilisateur et le changelog, mettre à jour la page Fonctionnalités si besoin.

Déploiement

Voir deploy/ : vhosts Apache (vdr.conf, vdr-ssl.conf), cron.d-vdr. Les dossiers hors public/ sont refusés par Apache. Fichiers sensibles : config/config.php (0640), storage/app.key (0600, propriétaire www-data).