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 fonctionsfind(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 dansAuth::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'sansunsafe-inline. Pas d'attributstyle=, pas de<script>en ligne, pas dejavascript:. Comportements via attributsdata-*danspublic/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) oumember.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 viaAuth::access()(membre ou super_admin, organisation active, IP danstenants.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)
Domain\Intel::enrich(): pays (tablegeoip, base db-ip Lite) et réputation (ip_reputation) des alertesenriched = 0. Plages stockées sur 16 octets (Net::bin16, IPv4 en::ffff:a.b.c.d) pour des comparaisons homogènes.Domain\Correlator::run(): alertes enrichies depuis le curseursettings.correlate.last_id; produit des alertes d'époque 0 (hosts.console_seq). Ne repasse jamais sur ses propres alertes.Domain\Notifier::queueAlerts()/queueMute()→ tablenotifications(file), puissendPending()(reprises 1/5/15/60 min). Curseursettings.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 tablesessionsne stocke que son SHA-256. - Étape
totp(5 min) après le mot de passe, puis nouvelle sessionok(anti-fixation). Totp::verify()renvoie le pas accepté, mémorisé dansusers.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 IGNOREsurUNIQUE(host_id, epoch, seq). epoch: incrémentée par la rotation de jeton « agent réinstallé ». Unseqconnu reçu avec un autre horodatage déclenche l'événementseq_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à deagent.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
- Côté agent : produire l'alerte (
Alert(ts, "mon_scenario", gravité, message, ip=…)). - 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. - S'il doit être partagé entre serveurs : l'ajouter à
agent.share_scenariosdans la configuration. - 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).