Aller au contenu

Masquer les secrets

redact remplace par [redacted] ce qu'il trouve, et redactDefaults ajoute un jeu de motifs choisis. Chaque règle s'applique à chaque chaîne qu'un appel à $debug remet au plugin (chaque argument, chaque clé et chaque chaîne d'un objet journalisé, le message et la pile d'appels d'une Error, l'espace de noms, un préfixe par appel, le chemin source) et à la ligne telle qu'elle sera affichée.

Le masquage est un filet de sécurité, pas un coffre-fort. Il rattrape un secret que vous avez nommé quand celui-ci se retrouve par erreur dans un appel à $debug : une ligne de log oubliée ne met donc pas un mot de passe dans votre terminal, votre fichier de log, un log de CI ou une capture d'écran. Il ne rend pas sûr le fait de journaliser un secret volontairement : le seul secret sûr est celui que vous ne journalisez jamais.

Entrées chaîne et entrées RegExp

L'endroit où une entrée s'applique dépend de son type :

EntréeMasqueEnvoyée à la page
RegExp, et chaque motif de redactDefaultsChaque surface : console du navigateur, terminal de développement, sortie SSR, fichier de log, Vue DevTools, test: 'log'Oui, telle qu'écrite
ChaîneLe côté serveur uniquement : terminal de développement, fichier de log, sortie SSR, test: 'log' hors du mode navigateur de VitestJamais
  • Une entrée chaîne est l'endroit où va la valeur réelle d'un secret, lue depuis l'environnement. Elle reste dans la mémoire du processus qui charge votre configuration : aucun module que le plugin génère ne la contient, elle n'atteint donc jamais le navigateur. Le serveur de développement la masque dans chaque ligne que le navigateur transmet, mais elle ne masque pas la console du navigateur ni Vue DevTools.
  • Une entrée RegExp est écrite dans l'environnement d'exécution que charge la page, où tout script de l'origine de développement peut la lire. Elle doit décrire la forme d'un secret, jamais le contenir. Pour masquer aussi une valeur dans la console du navigateur, ajoutez une RegExp pour sa forme qui couvre la valeur entière : dans le navigateur, c'est le seul type d'entrée qui s'applique.
ts
// vite.config.ts
import { defineConfig, loadEnv } from "vite";
import mtDebug from "@mars-tech/vite-debug-plugin";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), ""); // tout le .env, préfixe VITE_ ou non
  return {
    plugins: [
      mtDebug({
        redact: [
          env.DB_PASSWORD, // chaîne : côté serveur uniquement, toutes les occurrences
          /sk_live_[A-Za-z0-9]{8,}/, // RegExp : la forme d'un secret, masquée partout
          /(?<=x-api-key:\s*)\S+/, // une assertion arrière garde le nom de l'en-tête, masque la clé
          /(?<="password"\s*:\s*)"(?:[^"\\]|\\.)*"/, // seule la valeur est masquée
        ],
        redactDefaults: true, // masque aussi JWT, jetons Bearer, e-mails, clés sk_/pk_/rk_, valeurs JSON secrètes
      }),
    ],
  };
});
ts
$debug("info", { user: "ada@example.com", token: "Bearer abc.def" });
// MARS-TECH Info - {"user":"[redacted]","token":[redacted]}   (avec redactDefaults)

La valeur de token perd ses guillemets : le motif par défaut des clés secrètes masque toute la valeur entre guillemets et garde la clé grâce à une assertion arrière (lookbehind).

Une entrée chaîne correspond littéralement, à chaque occurrence, telle qu'écrite, et aussi telle que JSON l'échappe : un mot de passe contenant un ", un \ ou un saut de ligne est donc trouvé à l'intérieur d'un objet journalisé. Un nombre correspond au littéral sous lequel il s'affiche. Une valeur vide ou absente (env.X sans variable de ce nom) est ignorée ; toute autre valeur, un objet par exemple, est ignorée avec un avertissement.

Une entrée chaîne n'est pas mieux protégée que votre environnement de développement. Utilisez Vite 6.4.2, 7.3.2, 8.0.5 ou une version ultérieure : les versions antérieures remettent n'importe quel fichier à un script de la page, votre .env compris. Ne mettez jamais de clés de production dans un environnement de développement. Si votre code logue des secrets, même masqués, n'activez pas le fichier de log NDJSON : c'est un fichier sur le disque que tout outil qui lit des fichiers peut lire. Un log que vous gardez malgré tout est plus sûr hors du projet (hors de server.fs.allow, qui dans un monorepo est la racine de l'espace de travail), là où Vite refuse tout fichier : voir Tenir le log à l'écart de la page.

Écrire une entrée RegExp

  • Décrivez la forme, jamais la valeur. Écrivez /sk_live_[A-Za-z0-9]+/, pas la clé elle-même : une RegExp est envoyée à la page telle qu'écrite.
  • Couvrez le secret entier. Une RegExp masque toute sa correspondance, et rien de plus. Elle est appliquée avec le drapeau global forcé, pour masquer chaque occurrence, et le drapeau y retiré.
  • Gardez le texte qui précède un secret avec une assertion arrière. Un groupe de capture ne garde rien de la correspondance : la convention $1 de la 0.6 a disparu. /(?<=x-api-key:\s*)\S+/ masque la clé et garde le nom de l'en-tête, là où /x-api-key:\s*\S+/ masque les deux. Un motif de la 0.6 qui commence par un groupe, comme /("password"\s*:\s*)"[^"]*"/, masque désormais sa clé aussi ; réécrivez-le en /(?<="password"\s*:\s*)"[^"]*"/ (voir Chaque correspondance est masquée en entier).
  • Un motif voit chaque chaîne et la ligne. Une RegExp s'applique à chaque chaîne que votre code a passée, où ^ et $ s'ancrent à cette chaîne, et à la ligne affichée, où un objet est en JSON et où plusieurs valeurs sont jointes : /x-api-key:\s*\S+/ sur $debug("info", "x-api-key:", obj) atteint l'intérieur de l'objet. Dans la ligne, un " ou un saut de ligne à l'intérieur d'un objet est échappé (\", \n).
  • Gardez-la en temps linéaire. Le serveur de développement applique vos entrées RegExp à chaque ligne qu'une page transmet, un texte que tout script de l'origine de développement peut envoyer. Deux formes courantes ne sont pas linéaires : les quantificateurs imbriqués comme /(\w+\s?)+$/, et une classe répétée devant un littéral comme /[a-z]+@corp\.example/. L'une comme l'autre peut bloquer le serveur de développement plusieurs secondes sur une seule ligne forgée. Les motifs par défaut et les entrées chaîne sont linéaires.

Motifs par défaut

redactDefaults est facultatif (false par défaut). Il privilégie les motifs à forte confiance et ignore les suites de chiffres nues, si bien que les identifiants de commande et les horodatages restent lisibles. Il masque :

  • les JWT ;
  • les jetons Bearer ;
  • les identifiants Basic après un en-tête Authorization: (un « basic » isolé est un mot ordinaire) ;
  • les adresses e-mail ;
  • les clés de style Stripe, comme sk_live_…, pk_test_… ou rk_live_… ;
  • la valeur d'une clé secrète (password, passphrase, token, api_key, x-api-key, private_key, credentials, cookie et semblables), quand c'est une chaîne, un nombre ou un tableau plat de ceux-ci. La clé reste lisible. Cela vaut pour une propriété d'objet, pour une paire [key, value], la forme sous laquelle s'affichent une Map et Object.entries() (Map(1) [["password",[redacted]]]), et à l'intérieur du JSON que contient une chaîne journalisée : le body: JSON.stringify({ password }) des options d'un appel fetch affiche {"body":"{\"password\":[redacted]}"}.

Les motifs par défaut ne couvrent pas une valeur objet en entier (les clés secrètes qu'elle contient sont masquées une à une), un tableau qui contient un tableau ou un objet, un password=… encodé pour une URL, la syntaxe { password: '…' } de util.inspect, ni une valeur opaque comme ?token=deadbeefcafe1234. Ajoutez un motif pour ceux-ci.

Les motifs par défaut utilisent des assertions arrière : avec redactDefaults: true, un Safari qui exécute votre page de développement doit être en version 16.4 ou ultérieure. Il en va de même pour une de vos entrées RegExp écrite avec une assertion arrière.

Ce que le masquage couvre — et ne couvre pas

Le plugin garantit une chose, sur chaque surface qu'une entrée atteint : il n'introduit jamais de fuite. Chaque règle s'applique à chaque chaîne qu'un appel à $debug lui remet, exactement telle que votre code l'a passée (y compris la chaîne de format, un { tag } et les libellés de count(), time() et group()), et à la ligne telle qu'affichée. Chaque correspondance s'affiche [redacted], en entier, et rien de ce que fait le plugin ensuite n'en fait réapparaître une partie, qu'il s'agisse de la substitution printf, du rendu JSON, de la coupe d'un long objet à 500 caractères ou de l'envoi d'une ligne du navigateur au serveur de développement. Les filtres voient toujours le vrai nom : ?mt-debug=-user:* coupe ns("user:" + email) même quand l'e-mail est masqué.

Il ne couvre pas :

  • Un secret que votre code a transformé avant de le journaliser. C'est une autre chaîne : secret.slice(0, 8) + "…", encodeURIComponent(secret), new URL(secret).href, base64, util.inspect(…), ou du JSON que votre code a échappé deux fois. Ajoutez une entrée pour la forme journalisée, ou ne la journalisez pas.
  • { inspect: true }, qui remet l'objet vivant à la console du navigateur parce que vous l'avez demandé (voir Valeurs vivantes dans le navigateur).
  • Ce que $debug ne produit pas : console.log, les messages de Vite, les erreurs non interceptées, et les outils qui recopient la console du navigateur ailleurs.

Quelques conséquences à connaître :

  • Le build debug/ d'une bibliothèque n'embarque aucune règle de masquage : ses appels atteignent le $debug de votre application et sont masqués par vos redact et redactDefaults.
  • Plusieurs serveurs de développement dans un même processus Node (projets Vitest, builds client et SSR de Nuxt) masquent la sortie côté serveur les uns des autres avec toutes leurs règles, et une règle que vous retirez continue de masquer jusqu'au redémarrage du processus. Deux exécutions de vite ne partagent rien.
  • Un nombre printf peut être masqué dans le terminal. Avec des entrées chaîne configurées, un nombre %d, %i ou %f d'une ligne du navigateur qui se lit autrement que son argument s'affiche [redacted] dans le terminal et dans le fichier de log : $debug("info", "%d items", "007") affiche 7 items dans la console du navigateur et [redacted] items dans le terminal. Passez un entier, ou utilisez %s.

Valeurs vivantes dans le navigateur

Dans le navigateur, $debug remet normalement aussi la valeur vivante à la console (voir Inspection d'objets vivante). Dès qu'une règle de masquage côté navigateur est configurée, une entrée RegExp ou redactDefaults, il cesse de le faire : la console du navigateur et la chronologie de Vue DevTools reçoivent le texte masqué au lieu d'un objet vivant et dépliable, car un objet vivant contournerait le masquage textuel. Cela inclut les nœuds DOM, dont le href, les attributs data-* et la value d'un champ s'afficheraient sans masque. table() et dir() affichent le résumé masqué au lieu du tableau ou de l'arbre natif.

{ inspect: true } rétablit la valeur vivante pour un seul appel, dans la console seulement : Vue DevTools garde le texte masqué. table() et dir() n'ont pas d'équivalent : appelez directement console.table pour des données que vous savez sûres. Avec des entrées chaîne seulement, les valeurs vivantes sont conservées et table() et dir() s'affichent nativement, puisque ces entrées ne masquent de toute façon jamais le navigateur.

Piles d'appels des erreurs

Avec showStack, la pile d'appels d'une Error est masquée comme son message, et ce que les règles ont trouvé dans le message est masqué dans la pile aussi.

  • Les piles d'appels sont analysées, pas rendues sûres. Les URL des lignes de pile portent souvent des identifiants. Les motifs par défaut attrapent les JWT, les jetons Bearer, les e-mails et les clés sk_/pk_/rk_ dans une URL de ligne de pile, mais pas les valeurs opaques comme ?token=deadbeefcafe1234 ou ?X-Amz-Signature=…, qui ressemblent à de l'hexadécimal ordinaire. Ajoutez votre propre motif pour celles-ci.
  • Une ligne de pile masquée n'est plus cliquable. Réécrire le texte d'une URL de ligne de pile empêche les outils de développement d'en faire un lien et de résoudre sa source map, et un motif trop large (une expression régulière hexadécimale nue, par exemple) peut masquer des segments de chemin légitimes et les empreintes ?v= de Vite. Privilégiez des motifs étroits. Il en va de même pour un emplacement source dont une règle a trouvé le chemin.

Transfert de la console par Vite 8

Vite 8 peut recopier dans le terminal de développement ce que la page affiche avec console.error, console.warn ou toute méthode listée dans server.forwardConsole, et l'active de lui-même pour console.error et console.warn quand il détecte un agent de programmation. Cette copie afficherait une ligne $debug telle que la console du navigateur la contient : vos entrées chaîne en clair, et une valeur { inspect: true } non masquée. Aussi, dès qu'une règle de masquage est configurée et que vous n'avez pas défini server.forwardConsole, le plugin retire du transfert de Vite les méthodes de console avec lesquelles $debug affiche : les erreurs non interceptées sont toujours transférées, mais plus vos propres appels à console.error et console.warn.

Si vous définissez server.forwardConsole vous-même, il reste tel qu'écrit et vos propres appels sont transférés comme vous l'avez demandé ; $debug affiche alors avec les méthodes de console que la page avait avant que Vite ne les enveloppe, si bien qu'aucune de ses lignes ne prend ce chemin. Vite 6 et 7 n'ont pas cette option.

Là où les entrées chaîne n'arrivent pas

Chaque sortie côté Node lit vos entrées chaîne dans le processus qui charge votre configuration, à chaque affichage. Elles atteignent le processus Vite (le serveur de développement, ssrLoadModule, le module runner, vite-node), un fil d'exécution (worker thread) démarré après le plugin (le worker Nitro de Nuxt, les pools threads et vmThreads de Vitest), et, depuis Vitest 4, les workers forks et vmForks.

Là où elles ne peuvent pas être lues, $debug n'affiche rien, plutôt que d'afficher une ligne avec vos entrées chaîne non masquées, et le signale une fois. Le SSR dans un processus à part, par exemple, affiche :

text
[MARS-TECH] $debug prints nothing in this process: the string entries of your redact option do not reach it, and it does not print what it cannot mask. They reach SSR in the Vite process and in a worker thread started after the plugin, and Vitest workers (the forks and vmForks pools from Vitest 4). Elsewhere - SSR in a process of its own, workerd - describe each such value with a RegExp for its shape instead.
Avec des entrées chaîne, $debug n'affiche rien dansPour y voir la sortie
Le SSR dans un processus à part, dans workerd (@cloudflare/vite-plugin), ou dans un environnement d'exécution sans process.getBuiltinModuleUne RegExp pour la forme de chaque valeur
Les pools forks et vmForks de Vitest 3 (forks est sa valeur par défaut)Vitest 4, ou pool: "threads"
Un projet Vitest qui s'exécute dans un navigateur, et la page navigateur de VitestUne RegExp pour la forme de chaque valeur
Un fil d'exécution démarré avant le pluginRedémarrer le fil d'exécution
Un journaliseur que vous installez vous-même (installMtDebugConsole) dans un worker forks auquel rien n'a remis les règlestest: 'log'

Sans entrées chaîne, chaque sortie affiche ses lignes, masquées par vos entrées RegExp et les motifs par défaut. Pour les configurations concernées, voir Entrées chaîne de redact côté serveur et Entrées chaîne de redact dans les tests.