La première fois qu'un auditeur demande votre piste d'audit, vous exporterez probablement un CSV depuis le système de logs que vous faites déjà tourner, et ça aura l'air correct. Horodatages, noms d'utilisateur, actions, IDs de ressources, tout dans l'ordre. Puis quelqu'un pose la seule question qui compte vraiment : comment savons-nous que ce fichier correspond à ce qui s'est réellement passé ? Un journal d'audit immuable que personne en dehors de votre équipe ne peut vérifier de façon indépendante n'est pas une preuve. C'est une affirmation sur votre propre comportement, produite par des systèmes que vous contrôlez, dans un format que vous avez choisi. Les auditeurs restent polis à ce sujet. Les régulateurs le sont de moins en moins.
« Immuable » signifie, dans la plupart des stacks d'infrastructure, quelque chose de plus faible que ce que le mot suggère. Cela veut généralement dire append-only par convention - l'application ne fait qu'insérer, et tout le monde accepte de ne jamais lancer d'UPDATE. Parfois cela veut dire une politique de rétention sur un object store, ou un bucket WORM, qui protège contre la suppression mais ne dit rien de ce qui a été écrit au départ. Ce sont tous des contrôles réels. Aucun ne permet à un tiers de détecter un changement après coup. Cet écart - entre personne n'est censé modifier ceci et vous pouvez prouver que personne ne l'a fait - est tout le sujet.
Comment fonctionne une piste d'audit infalsifiable
Prenez chaque champ important d'une entrée de log - l'action, qui l'a effectuée, pour quelle organisation, la ressource, les valeurs modifiées, l'ID de corrélation, le niveau de risque, l'adresse du client - sérialisez-les dans un ordre fixe et convenu, ajoutez le hash de l'entrée précédente, et faites tourner SHA-256 sur le résultat. Stockez ce condensé sur la ligne sous le nom entry_hash, et stockez le condensé du prédécesseur sous prev_hash. La première ligne n'a pas de prédécesseur, donc son prev_hash est nul. Tout ce qui vient après est soudé à ce qui précède.
La conséquence, c'est la partie utile. Changez un seul caractère dans une entrée vieille de trois mois, et son hash recalculé ne correspond plus à celui stocké sur la ligne. Supprimez une ligne entièrement, et le prev_hash de la ligne suivante pointe vers un prédécesseur qui n'est plus là. Insérez une entrée fabriquée, et elle n'a tout simplement aucune place valide dans la séquence. Vous n'avez pas besoin de faire confiance à la couche de stockage, à l'opérateur, ou à l'éditeur. Vous avez besoin des entrées, des règles de hachage, et d'un script. C'est ce qui en fait une piste d'audit infalsifiable plutôt qu'un log bien tenu.
Soyons précis sur ce que cela ne vous donne pas. Une chaîne de hachage est infalsifiable de façon détectable, pas inviolable. Un attaquant qui dispose d'un accès en écriture à la base de données et connaît les règles de hachage peut réécrire une entrée, puis recalculer tous les hash suivants, produisant une chaîne qui se vérifie parfaitement. Ce qui contre cela, c'est de publier la tête de chaîne quelque part que l'attaquant ne contrôle pas : signer l'entry_hash courant avec une clé détenue hors de la base de données, l'exporter, l'envoyer à un auditeur, l'écrire dans un système séparé. N'importe laquelle de ces options gèle l'historique jusqu'à ce moment-là.
Pourquoi un writer unique compte plus qu'on ne le pense
Une chaîne de hachage a exactement une seule tête, et chaque nouvelle entrée doit la lire avant de pouvoir s'y raccrocher. Faites tourner deux writers sur la même table et ils entreront en compétition - les deux lisent la même tête, les deux s'y raccrochent, et l'un des deux se retrouve faux. La chaîne se signale elle-même cassée, et vous passez une journée à chercher un attaquant qui n'existe pas. Nous traitons cela comme un invariant plutôt qu'une préférence : un seul processus consomme la queue d'audit et écrit dans la table, et activer un second writer est un cutover délibéré, pas un simple curseur de scaling.
Les échecs de vérification doivent aussi être lisibles, parce que les opérateurs réagissent au premier mot qu'ils lisent. Notre vérificateur remonte trois raisons distinctes. chain_broken signifie que le chaînage est incorrect, que prev_hash ne correspond pas au prédécesseur. content_tampered signifie que le chaînage est intact mais que recalculer le hash à partir des champs stockés produit un résultat différent. entry_never_hashed signifie que la ligne a été écrite sans hash du tout. Seul le second cas est une preuve de falsification, et fondre les trois en un seul message est la meilleure façon de déclencher un incident de sécurité à cause d'un bug dans un test.
Ce troisième cas n'est pas hypothétique, et ça vaut le coup de le raconter contre nous-mêmes. Une poignée de lignes de notre propre chaîne ont été écrites par un test qui insérait directement dans la table en SQL brut, contournant le writer, laissant les deux colonnes de hash vides. Les triggers de base de données qui rendent la table append-only refusent aussi bien les deletes que les updates, ce qui signifie que ces lignes sont définitivement irréparables. Elles resteront dans la chaîne pour toujours, et chaque exécution de vérification les atteint. Le correctif a été un helper que les tests sont désormais obligés d'utiliser, plus un test de garde qui échoue dès que quelqu'un retente du SQL brut.
Ce qu'un auditeur devrait réellement recevoir
Le livrable n'est pas une capture d'écran d'un tableau de bord disant « chaîne valide ». C'est un CSV contenant les entrées elles-mêmes avec les deux colonnes de hash incluses - id, action, acteur, organisation, périmètre client, type et ID de ressource, niveau de risque, ID de corrélation, adresse, user agent, prev_hash, entry_hash et horodatage - plus une spécification écrite précisant exactement quels champs entrent dans le condensé et dans quel ordre. Avec ces deux éléments, un auditeur écrit vingt lignes de Python et vérifie votre travail sans rien vous demander d'autre. C'est ça, une preuve : quelque chose que vous pouvez remettre, puis en perdre le contrôle.
Un petit détail aux conséquences bien réelles : les champs d'audit exportés sont des chaînes potentiellement influencées par un attaquant, et les tableurs exécutent tout ce qui commence par un signe égal, plus, moins ou arobase. Un export d'audit qui ouvre un shell sur l'ordinateur de l'auditeur est une façon mémorable de rater un audit. Chaque champ de notre export est échappé, et les valeurs préfixées par une formule sont neutralisées avant d'atteindre le fichier. C'est le genre de contrôle qui ressemble à du zèle jusqu'au moment où la première personne ouvre le dossier de preuves dans Excel.
Sous tout cela, la table elle-même doit refuser les opérations dangereuses. L'append-only appliqué en code applicatif est une promesse qui survit exactement jusqu'à ce que quelqu'un écrive une migration, un script de nettoyage, ou un correctif bien intentionné à deux heures du matin. Des triggers de base de données qui rejettent UPDATE et DELETE sur la table d'audit transforment cette promesse en contrainte. Cela signifie aussi que l'application et le stockage ne peuvent être en désaccord que dans un seul sens : la base de données peut refuser une écriture que l'application voulait, mais l'application ne peut jamais réécrire discrètement la base de données.
Vérifiez votre journal d'audit immuable selon un calendrier
La plupart des équipes qui ont un endpoint de vérification l'appellent une seule fois, pendant la démo. C'est la mauvaise cadence. Une rupture de chaîne découverte quand l'auditeur la demande est un problème forensique couvrant tous les mois qui se sont écoulés depuis ; la même rupture découverte par une exécution planifiée est un rapport de bug avec un horodatage. La vérification sur une grande table doit être conçue pour ça. La nôtre parcourt les entrées par pages ordonnées plutôt que de tout charger en mémoire, parce que la table grossit à chaque mutation sur la plateforme, et un vérificateur qui ne fonctionne que sur de petites tables est un vérificateur qui finira par ne plus fonctionner.
L'autre habitude, c'est la couverture. Il est facile de mettre l'audit logging dans le middleware HTTP, de regarder chaque mutation API apparaître dans la piste, et de déclarer que c'est terminé. Puis un cron job archive les données d'un client, un consumer de queue révoque un agent, un worker en arrière-plan fait tourner un credential, et rien de tout cela n'est dans la piste, parce que rien de tout cela n'était une requête HTTP. Ces chemins doivent logger explicitement. La vérité inconfortable, c'est que les actions les moins susceptibles d'être loggées sont exactement celles qu'un enquêteur veut le plus voir.
NIS2 et DORA poussent dans la même direction : des contrôles que vous pouvez démontrer plutôt qu'affirmer. Personne ne va vous remettre un certificat pour avoir utilisé SHA-256, et nous n'en revendiquons aucun. Mais quand quelqu'un demande qui a changé la règle de pare-feu le 3 mars, il y a une grande différence entre un résultat de requête et un résultat de requête dont un inconnu peut vérifier l'intégrité. Trois questions à poser à quiconque vous vend un journal d'audit immuable : puis-je exporter les hash, puis-je les recalculer sans votre logiciel, et que se passe-t-il quand la chaîne se signale elle-même cassée ?