2727 * désactiver le caviardage. Un garde trop zélé finit désarmé.
2828 */
2929
30+ import { bornesDuGroupe , decouper , type BorneSecrete } from "./spansSecrets.ts" ;
31+ import { nomDEnvironnement , valeurPlausiblementSecrete } from "./valeurPlausible.ts" ;
32+
3033/** Longueur en-dessous de laquelle on masque TOUT. */
3134export const SEUIL_MASQUAGE_TOTAL = 18 ;
3235export const TETE_VISIBLE = 6 ;
@@ -96,6 +99,26 @@ const NOMS_SECRETS = new Set([
9699 "credential" ,
97100 "credentials" ,
98101 "bearer" ,
102+ // ── Les composés, ajoutés le 03/08 ────────────────────────────────────
103+ // `R2_SECRET_ACCESS_KEY` sortait EN CLAIR : la liste connaissait `api_key`
104+ // et `private_key`, mais ni `access_key`, ni `secret_key`, ni la forme à
105+ // TROIS segments. C'est la famille des clés d'objet (R2, S3, Supabase) —
106+ // celles du data-lake. Trouvé par l'audit adversarial, rejoué ici.
107+ "access_key" ,
108+ "secret_key" ,
109+ "secret_access_key" ,
110+ "access_key_id" ,
111+ "encryption_key" ,
112+ "signing_key" ,
113+ "role_key" ,
114+ "service_key" ,
115+ "session_key" ,
116+ "webhook_secret" ,
117+ "auth_token" ,
118+ "session_token" ,
119+ "connection_string" ,
120+ "dsn" ,
121+ "passphrase" ,
99122] ) ;
100123
101124const normaliser = ( nom : string ) => nom . toLowerCase ( ) . replaceAll ( / [ \s - ] / gu, "_" ) ;
@@ -117,7 +140,9 @@ export function nomSensible(nom: string): boolean {
117140 const propre = normaliser ( nom . trim ( ) ) ;
118141 if ( NOMS_SECRETS . has ( propre ) || NOMS_SECRETS . has ( propre . replaceAll ( "_" , "" ) ) ) return true ;
119142 const segments = propre . split ( "_" ) . filter ( ( part ) => part . length > 0 ) ;
120- for ( let taille = 1 ; taille <= Math . min ( 2 , segments . length - 1 ) ; taille += 1 ) {
143+ // TROIS segments, pas deux : `secret_access_key` en fait trois, et c'est
144+ // exactement la forme des clés d'objet qui sortaient en clair.
145+ for ( let taille = 1 ; taille <= Math . min ( 3 , segments . length - 1 ) ; taille += 1 ) {
121146 const suffixe = segments . slice ( segments . length - taille ) . join ( "_" ) ;
122147 if ( NOMS_SECRETS . has ( suffixe ) || NOMS_SECRETS . has ( suffixe . replaceAll ( "_" , "" ) ) ) return true ;
123148 }
@@ -136,11 +161,11 @@ export function nomSensible(nom: string): boolean {
136161
137162/** `FOO=valeur`, en shell comme en `.env`. */
138163const AFFECTATION_ENV =
139- / \b ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) \s * = \s * (?: " ( [ ^ " \n ] * ) " | ' ( [ ^ ' \n ] * ) ' | ( [ ^ \s ; & | ] + ) ) / gu;
164+ / \b ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) [ ^ \S \r \n ] * = [ ^ \S \r \n ] * (?: " ( [ ^ " \n ] * ) " | ' ( [ ^ ' \n ] * ) ' | ( [ ^ \s ; & | ] + ) ) / gu;
140165
141166/** `"clé": "valeur"` en JSON, `clé: valeur` en YAML. */
142167const AFFECTATION_OBJET =
143- / (?: " ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) " | \b ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) ) \s * : \s * (?: " ( [ ^ " \n ] * ) " | ( [ ^ \s , } \n ] + ) ) / gu;
168+ / (?: " ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) " | \b ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) ) [ ^ \S \r \n ] * : [ ^ \S \r \n ] * (?: " ( [ ^ " \n ] * ) " | ( [ ^ \s , } \n ] + ) ) / gu;
144169
145170/**
146171 * Les noms que la passe d'affectation NE doit pas retoucher.
@@ -154,7 +179,16 @@ const AFFECTATION_OBJET =
154179const DEJA_TRAITES = new Set ( [ "authorization" , "proxy_authorization" ] ) ;
155180
156181/** Un en-tête d'autorisation, quel que soit son schéma. */
157- const EN_TETE_AUTH = / \b ( a u t h o r i z a t i o n | p r o x y - a u t h o r i z a t i o n ) \s * : \s * ( \S + ) ( \s + \S + ) ? / giu;
182+ /**
183+ * ⚠️ `[^\S\r\n]` et JAMAIS `\s` : `\s` traverse les retours à la ligne, donc
184+ * `Authorization:` en fin de ligne capturait le premier mot de la ligne
185+ * SUIVANTE et le remplacement les fusionnait. Mesuré le 03/08 sur le dépôt :
186+ * 459 lignes PERDUES, tout le fichier décalé après le premier en-tête. Un
187+ * agent recevait un fichier dont les numéros de ligne ne collaient plus au
188+ * disque.
189+ */
190+ const EN_TETE_AUTH =
191+ / \b ( a u t h o r i z a t i o n | p r o x y - a u t h o r i z a t i o n ) [ ^ \S \r \n ] * : [ ^ \S \r \n ] * ( \S + ) ( [ ^ \S \r \n ] + \S + ) ? / giu;
158192
159193/** Les paramètres d'URL sensibles — le nom décide, là encore. */
160194const PARAM_URL = / ( [ ? & ] ) ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * ) = ( [ ^ & \s " ' ] + ) / gu;
@@ -189,59 +223,129 @@ const CLE_PRIVEE = /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE
189223 */
190224export function caviarder ( texte : string ) : string {
191225 if ( texte . length === 0 ) return texte ;
192- let sortie = texte . replaceAll (
193- CLE_PRIVEE ,
194- "-----BEGIN PRIVATE KEY----- *** -----END PRIVATE KEY-----" ,
195- ) ;
196226
227+ // ── LE DÉCOUPAGE, ET PLUS JAMAIS LA RECONSTRUCTION ────────────────────────
228+ //
229+ // Chaque passe ne rend que des BORNES ; le texte hors bornes est recopié à
230+ // l'octet près (cf. spansSecrets.ts). Trois conséquences, toutes voulues :
231+ // aucune structure ne peut être réécrite, aucun retour à la ligne ne peut
232+ // disparaître, et un remplacement contenant « $& » ne peut pas se
233+ // ré-injecter puisque le masque passe par un appel de fonction.
234+ //
235+ // Et LE NOM NE DÉCIDE PLUS SEUL. Une clé nommée `token` dont la valeur est
236+ // `none`, `${{ secrets.X }}` ou `mcpSession.authorizationHeader` n'est pas un
237+ // secret : `valeurPlausiblementSecrete` la laisse passer. Mesuré avant :
238+ // 800 fichiers du dépôt altérés sur 15 255, sans qu'aucun ne contienne le
239+ // moindre secret.
240+ const bornes : BorneSecrete [ ] = [ ] ;
241+
242+ // Les blocs PEM en premier : ils contiennent des retours à la ligne et des
243+ // « = » qui feraient dérailler tout le reste.
244+ for ( const trouve of texte . matchAll ( CLE_PRIVEE ) ) {
245+ if ( trouve . index !== undefined ) {
246+ bornes . push ( { debut : trouve . index , fin : trouve . index + trouve [ 0 ] . length } ) ;
247+ }
248+ }
249+
250+ // Les jetons à préfixe connu. Ils se reconnaissent SEULS, où qu'ils soient :
251+ // aucun test de nom, aucun test de plausibilité — le préfixe EST la preuve.
197252 for ( const motif of JETONS ) {
198- sortie = sortie . replaceAll ( motif , ( trouve ) => masquer ( trouve ) ) ;
253+ for ( const trouve of texte . matchAll ( motif ) ) {
254+ if ( trouve . index !== undefined ) {
255+ bornes . push ( { debut : trouve . index , fin : trouve . index + trouve [ 0 ] . length } ) ;
256+ }
257+ }
199258 }
200259
201- // Le mot de passe d'une URL. APRÈS la passe JETONS, et sans jamais la
202- // repasser : un `ghp_…` en position mot de passe a déjà été masqué en
203- // gardant sa tête reconnaissable — la retoucher détruirait précisément ce
204- // qu'elle avait choisi de garder. D'où le refus des valeurs portant `***`.
205- sortie = sortie . replaceAll ( MOT_DE_PASSE_URL , ( entier , avant : string , motDePasse : string ) =>
206- motDePasse . includes ( "***" ) ? entier : `${ avant } :***@` ,
260+ // Le mot de passe d'une URL : seule la position mot de passe tombe.
261+ // L'utilisateur et l'hôte restent lisibles — savoir QUEL compte sur QUEL
262+ // hôte est ce qui rend une trace utile, et `ssh://git@github.com` (un
263+ // utilisateur sans mot de passe) doit traverser intact.
264+ // Le `***` refusé ici est ce qui rend l'opération IDEMPOTENTE : sans lui, un
265+ // texte déjà caviardé se fait re-caviarder et le masque grossit à chaque
266+ // passage. Un caviardage qui n'est pas idempotent finit par tout effacer.
267+ bornes . push (
268+ ...bornesDuGroupe (
269+ texte ,
270+ MOT_DE_PASSE_URL ,
271+ 2 ,
272+ ( valeur ) => valeur . length > 0 && ! valeur . includes ( "***" ) ,
273+ ) ,
207274 ) ;
208275
209- // L'en-tête d'autorisation garde son SCHÉMA : savoir que c'est un Bearer
210- // plutôt qu'un Basic est utile pour déboguer, et ne révèle rien.
211- //
212- // `MARQUE` est là parce que la passe des affectations qui suit voit
213- // `Authorization: Bearer` comme un couple clé/valeur et masquait « Bearer »
214- // à son tour — le journal rendait alors « Authorization: *** opaqu***2345 »,
215- // où l'on avait perdu le schéma ET gardé le secret masqué deux fois.
216- sortie = sortie . replaceAll ( EN_TETE_AUTH , ( entier , nom : string , un : string , deux ?: string ) =>
217- deux === undefined ? `${ nom } : ${ masquer ( un ) } ` : `${ nom } : ${ un } ${ masquer ( deux . trim ( ) ) } ` ,
276+ // L'en-tête d'autorisation. Le SCHÉMA reste (« Bearer », « Basic ») : il
277+ // aide à déboguer et ne révèle rien. Seule la partie qui suit est masquée,
278+ // et seulement si elle est plausible — dans du code source,
279+ // `Authorization: mcpSession.authorizationHeader` n'est pas un secret.
280+ bornes . push (
281+ ...bornesDuGroupe ( texte , EN_TETE_AUTH , 3 , ( valeur ) =>
282+ valeurPlausiblementSecrete ( valeur . trim ( ) ) ,
283+ ) ,
218284 ) ;
219-
220- sortie = sortie . replaceAll ( PARAM_URL , ( entier , sep : string , nom : string , valeur : string ) =>
221- nomSensible ( nom ) ? `${ sep } ${ nom } =${ masquer ( valeur ) } ` : entier ,
285+ bornes . push (
286+ ...bornesDuGroupe (
287+ texte ,
288+ EN_TETE_AUTH ,
289+ 2 ,
290+ ( valeur , entier ) => entier [ 3 ] === undefined && valeurPlausiblementSecrete ( valeur ) ,
291+ ) ,
222292 ) ;
223293
224- const masquerAffectation = ( entier : string , nom : string , valeur : string | undefined ) => {
225- if ( valeur === undefined || valeur . length === 0 ) return entier ;
226- if ( DEJA_TRAITES . has ( normaliser ( nom ) ) ) return entier ;
227- if ( ! nomSensible ( nom ) ) return entier ;
228- // Déjà caviardé par une passe précédente : y retoucher détruirait ce
229- // qu'elle avait délibérément gardé (la tête d'une clé reconnaissable).
230- if ( valeur . includes ( "***" ) ) return entier ;
231- return entier . replace ( valeur , masquer ( valeur ) ) ;
294+ // Les paramètres d'URL, puis les affectations : le nom donne l'indice, la
295+ // valeur donne le verdict.
296+ const nommeEtPlausible = ( valeur : string , entier : RegExpExecArray , indexDuNom : number ) => {
297+ const nom = entier [ indexDuNom ] ;
298+ return (
299+ nom !== undefined &&
300+ nomSensible ( nom ) &&
301+ valeurPlausiblementSecrete ( valeur , nomDEnvironnement ( nom ) )
302+ ) ;
232303 } ;
233304
234- sortie = sortie . replaceAll (
235- AFFECTATION_ENV ,
236- ( entier , nom : string , guillemets ?: string , apostrophes ?: string , nu ?: string ) =>
237- masquerAffectation ( entier , nom , guillemets ?? apostrophes ?? nu ) ,
238- ) ;
305+ bornes . push ( ...bornesDuGroupe ( texte , PARAM_URL , 3 , ( v , e ) => nommeEtPlausible ( v , e , 2 ) ) ) ;
306+
307+ for ( const groupe of [ 2 , 3 , 4 ] ) {
308+ bornes . push (
309+ ...bornesDuGroupe ( texte , AFFECTATION_ENV , groupe , ( v , e ) => nommeEtPlausible ( v , e , 1 ) ) ,
310+ ) ;
311+ }
239312
240- sortie = sortie . replaceAll (
241- AFFECTATION_OBJET ,
242- ( entier , cite ?: string , nu ?: string , valeurCitee ?: string , valeurNue ?: string ) =>
243- masquerAffectation ( entier , cite ?? nu ?? "" , valeurCitee ?? valeurNue ) ,
313+ for ( const groupe of [ 3 , 4 ] ) {
314+ bornes . push (
315+ ...bornesDuGroupe ( texte , AFFECTATION_OBJET , groupe , ( valeur , entier ) => {
316+ const nom = entier [ 1 ] ?? entier [ 2 ] ;
317+ if ( nom === undefined || DEJA_TRAITES . has ( normaliser ( nom ) ) ) return false ;
318+ return nomSensible ( nom ) && valeurPlausiblementSecrete ( valeur , nomDEnvironnement ( nom ) ) ;
319+ } ) ,
320+ ) ;
321+ }
322+
323+ return decouper ( texte , bornes , ( valeur ) =>
324+ CLE_PRIVEE_TEST . test ( valeur ) ? masquerBlocPem ( valeur ) : masquer ( valeur ) ,
244325 ) ;
326+ }
245327
246- return sortie ;
328+ /** Reconnaît un bloc PEM déjà borné, pour lui donner son masque dédié. */
329+ const CLE_PRIVEE_TEST = / ^ - - - - - B E G I N [ ^ - ] * P R I V A T E K E Y - - - - - / u;
330+
331+ /**
332+ * Masque une clé privée SANS changer le nombre de lignes.
333+ *
334+ * Un bloc PEM est le seul secret qui s'étend sur plusieurs lignes. L'écraser
335+ * en une seule ligne — ce que faisait l'ancienne version — décale tout le
336+ * fichier à partir de là. C'est exactement la corruption qu'on vient de
337+ * refermer, et elle ne redevient pas acceptable sous prétexte que le secret,
338+ * lui, est vrai : un agent qui lit ce fichier doit garder des numéros de ligne
339+ * justes.
340+ *
341+ * On garde donc les bornes du bloc, on remplace CHAQUE ligne du corps par
342+ * `***`, et le compte de lignes est inchangé — invariant tenu, secret parti.
343+ */
344+ function masquerBlocPem ( bloc : string ) : string {
345+ const lignes = bloc . split ( "\n" ) ;
346+ if ( lignes . length <= 2 ) return "-----BEGIN PRIVATE KEY----- *** -----END PRIVATE KEY-----" ;
347+ const premiere = lignes [ 0 ] ?? "" ;
348+ const derniere = lignes . at ( - 1 ) ?? "" ;
349+ const corps = lignes . slice ( 1 , - 1 ) . map ( ( ) => "***" ) ;
350+ return [ premiere , ...corps , derniere ] . join ( "\n" ) ;
247351}
0 commit comments