Masquage des données sensibles
QuietGuard\Monitor\Support\Scrubber masque les valeurs sensibles par nom avant que quoi que ce soit ne quitte le processus. Il est agnostique vis-à-vis du framework. Il parcourt des tableaux et, à partir de la version 0.3.1, ouvre aussi les chaînes qui portent elles-mêmes des valeurs nommées : un JSON écrit en chaîne, une URL.
Configurer les clés
Passez les termes à masquer au constructeur. Ils sont stockés en minuscules, et une clé correspond dès que son nom contient l'un d'eux, sans tenir compte de la casse :
Fonctionnement
scrub(array $data): array parcourt le tableau récursivement. Quand une clé (de type chaîne) contient l'un des termes configurés, toute sa valeur est remplacée par la constante de masque ; sinon les tableaux imbriqués sont masqués à leur tour et, à partir de la version 0.3.1, une valeur de type chaîne est lue elle aussi (voir Les chaînes qui portent des valeurs).
Remarques sur le comportement :
- Le masque est la constante publique
Scrubber::MASK, qui vaut'[scrubbed]'. - La comparaison porte sur des noms, jamais sur la forme d'une valeur : le nom d'une clé et, à partir de la version 0.3.1, celui d'un paramètre de requête ou d'une clé JSON trouvés dans une chaîne. La seule exception est le segment de chemin d'URL en forme de jeton, décrit plus bas. Reconnaître une valeur à sa forme est le rôle de l'autre moitié.
- La comparaison est une recherche de sous-chaîne insensible à la casse : il suffit que le nom de la clé contienne un terme configuré, si bien qu'un
passwordconfiguré masque aussiuser_passwordetPASSWORD_CONFIRMATION. - À partir de la version 0.3.1, un tiret et un souligné comptent pour le même caractère. La requête de Symfony, que Laravel utilise aussi, nomme chaque en-tête en minuscules avec des tirets : le mot de passe d'une authentification HTTP Basic y arrive sous
php-auth-pwet une clé d'API sousx-api-key, quephp_auth_pwetapi_keymasquent désormais. - À partir de la version 0.3.1, un terme qui porte un tiret ou un souligné entre deux de ses caractères est aussi cherché sans eux, dans un nom débarrassé des siens :
api_keymasque ainsiapikey(l'en-tête de Supabase et de Kong),apiKey(un champ de formulaire),x-apikeyetX-API-KEY. Un terme sans séparateur se compare comme avant, et un terme qui commence ou finit par un séparateur, comme ledb_de WordPress, garde son orthographe : sans lui,dbmasqueraitfeedback. Le coût est accepté : lelogged_inde la liste WordPress masque aussiisLoggedInetloggedInAt. - Une clé qui correspond voit toute sa valeur masquée, qu'il s'agisse d'un scalaire ou d'un tableau imbriqué.
- Les clés numériques / de liste ne sont jamais considérées comme sensibles ; seules les clés de type chaîne sont comparées.
Les chaînes qui portent des valeurs
À partir de la version 0.3.1, trois sortes de chaînes sont ouvertes, parce qu'elles portent des valeurs nommées qu'aucune clé du tableau ne nomme.
Un objet ou un tableau JSON écrit en chaîne, comme l'instantané (snapshot) qu'un composant Livewire renvoie à chaque requête, est décodé, masqué par clé comme n'importe quel tableau, puis réécrit. Quand rien n'y était masqué, il repart octet pour octet tel qu'il est venu ; quand il contenait un secret et ne peut pas être réécrit, il est remplacé entier par le masque. Quand il ne se décode pas, parce qu'il est tronqué ou qu'un masque par forme y a pris la place d'un nombre ([redacted:card]), il est remplacé entier par le masque dès qu'un nom entre guillemets suivi d'un : y correspond ("password":), comparé comme une clé, y compris après un guillemet égaré qui décale les paires ; sinon il repart tel qu'il est venu.
Une chaîne qui commence par une URL absolue, http ou https, des espaces de tête compris, passe par scrubUrl(string $url): string, que vous pouvez aussi appeler directement :
- la valeur de chaque paramètre de la chaîne de requête dont le nom correspond est masquée, et celle de chaque paire
nom=valeurd'un fragment dont le nom correspond aussi (#access_token=...). Le masque y est encodé,%5Bscrubbed%5D, pour que l'adresse reste une adresse ; - à partir de la version 0.3.1, quatre noms de paramètre sont masqués quelle que soit la liste, parce qu'une adresse qui les porte est elle-même un identifiant jusqu'à son expiration :
hash(le lien de connexion de Symfony, à côté deuseretexpires),_hash(la signature de l'UriSignerde Symfony, à côté de_expiration),sig(la signature d'accès partagé d'Azure, SAS) etsignature. Le nom doit être exact, comparé décodé et sans tenir compte de la casse :hashtagoucontent_hashrestent. La règle ne vaut que dans une adresse : une clé de tableau, une clé JSON et une ligne de code source qui appellehash('sha256', ...)ne sont pas concernées. La liste est la constanteScrubber::URL_CREDENTIALS; - tout segment de chemin fait de quarante lettres ou chiffres d'affilée, ou plus, est masqué d'office, quelle que soit la liste : c'est là que le lien de réinitialisation de Laravel (Breeze, Fortify, Jetstream) porte son jeton,
/reset-password/{token}. Un nombre, un slug, un UUID ou un ULID restent, puisque le chemin dit où l'application a cassé ; - le schéma, l'hôte, le port et l'utilisateur d'un
utilisateur:mot-de-passe@passent tels quels ; son mot de passe est masqué (utilisateur:%5Bscrubbed%5D@), puisqu'une URL saisie dans un champ de formulaire ou un réglage de webhook l'y porte. Un identifiant écrit à la place de l'utilisateur (https://ghp_...@github.com) reste donc, avec l'utilisateur. Un mot de passe qui contient un/, un?ou un#non encodé n'est pas reconnu, l'adresse s'arrêtant avant : écrivez-le encodé (%2F,%3F,%23), comme une URL le demande ; - l'URL s'arrête au premier espace, et le texte qui la suit reste tel quel, sauf une paire ouverte par
&,?ou#dont le nom correspond : une adresse écrite à la main avec un espace non encodé (?q=bonjour tout le monde&token=abc) y repousse la fin de sa propre chaîne de requête ; - une adresse relative (
/reset?token=...), ou une URL qui n'ouvre pas la chaîne, n'est pas atteinte.
Une chaîne qui commence par une adresse d'un autre schéma, un DSN le plus souvent (mysql://root:secret@db/app, redis://:secret@cache:6379), des espaces de tête compris, voit le mot de passe de son utilisateur:mot-de-passe@ masqué de la même façon, et la valeur de chaque paramètre de sa chaîne de requête dont le nom correspond, puisqu'un DSN y écrit ses options, un mot de passe parmi elles (tcp://127.0.0.1:6379?password=..., que Predis lit). La règle des segments de chemin en forme de jeton reste celle du web : le chemin d'un DSN est un nom de base ou un fichier.
Les lignes de code source
scrubLines(array $lines): array masque les lignes d'un extrait de code, et scrubSnippets(array $frames): array l'applique à l'extrait (code.lines) que chaque frame peut porter. Une ligne est remplacée entière par le masque quand un identifiant qui contient un terme de la liste, ou un mot de Scrubber::LINE_NEEDLES (key, auth, credential, salt, private, dsn, bearer, sk_, pk_), reçoit une valeur :
- une affectation ou un séparateur de clé :
=,=>ou un:seul ($secret = '...','api_key' => '...') ; - à partir de la version 0.3.1, un indice nommé entre guillemets suivi d'un
=($headers['Authorization'] = '...') ; - un nom entre guillemets avant une virgule (
define('API_KEY', '...')) ; - un appel dont le premier argument est un littéral (
setApiKey('sk_live_...')).
Une comparaison par == ou === dont le nom est à gauche compte comme une valeur, pour une variable comme pour un indice (if ($token == $expected), if ($config['password'] === '...')) : son autre membre peut être le secret lui-même, écrit dans le code. !=, !== et une comparaison dont le nom est à droite (if ('...' === $token)) ne comptent pas, et la ligne voyage. Une ligne qui ne fait qu'utiliser le nom reste : Hash::check($password, $hash), return csrf_token();, return $headers['Authorization'];. À partir de la version 0.3.1, :: est une portée et non un séparateur, si bien que Auth::user() ou TokenMismatchException::expired() restent aussi ; la version 0.3.0 masquait ces lignes, celle de la levée comprise. À partir de la même version, un terme est aussi essayé sous son autre graphie quand un tiret ou un souligné y sépare deux lettres ou chiffres, php_auth_pw comme php-auth-pw, pour qu'un 'php-auth-pw' => '...' codé en dur parte masqué. La ligne elle-même n'est jamais réécrite, et un terme qui commence ou finit par un séparateur, comme le db_ de WordPress ou les mots sk_ et pk_, garde sa seule graphie : l'autre commence ou termine un mot ordinaire ('db-new', 'disk-usage' => 90).
Toute autre forme voyage telle quelle : un littéral qui ne nomme aucun de ces mots, ou une affectation par un indice qui n'est pas entre guillemets ($data[$key] = '...').
Où il est appliqué
Le Reporter exécute le scrubber automatiquement :
reportException()masque le contexte de l'exception et les lignes d'extrait de sa trace (scrubSnippets()) avant l'envoi.sendLogs()masque le tableau de logs avant l'envoi. À partir de la version 0.3.1, les chaînes y sont ouvertes comme ailleurs, le message compris : quand il commence par une URL ou qu'il est du JSON, les valeurs qu'il porte sont masquées selon les mêmes règles, et le reste de son texte est gardé.sendDependencies()ne masque pas: un manifeste de dépendances contient des noms et versions de packages, pas des secrets.
Si vous construisez les payloads vous-même, appelez scrub() sur le tableau concerné avant de le confier à un transport.
L'autre moitié : le masquage par forme
Un nom de clé ne voit jamais une adresse écrite dans le texte libre d'un message d'erreur, dans un segment d'URL ou dans un champ que quelqu'un a appelé reference. Support\ValueRedactor lit la valeur, et le Reporter l'applique à tout le payload en dernier avant l'envoi, exceptions comme logs.
Il est actif par défaut, avec email, iban, nir, card et phone. Les formes porteuses d'une clé de contrôle sont vérifiées et pas seulement reconnues, si bien qu'une référence de commande à seize chiffres n'est pas prise pour un numéro de carte, et chaque masque nomme ce qu'il a caché ([redacted:email]) pour que le message reste lisible. Il se configure par Config : redact choisit les formes, customRedactions ajoute les vôtres.
Vous lisez la documentation PHP Core v1.0.