Skip to content

Commit f253bf9

Browse files
authored
Merge pull request #21 from Palenza/raptor/caviardage
Le caviardage repérait et RECONSTRUISAIT — il repère et découpe
2 parents 334a528 + 82d8453 commit f253bf9

8 files changed

Lines changed: 755 additions & 55 deletions

File tree

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3678,8 +3678,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
36783678
PostToolUse: [
36793679
{
36803680
hooks: [
3681-
(entree: { readonly tool_response?: unknown }) =>
3682-
Promise.resolve(garderLaSortie(entree.tool_response) ?? { continue: true }),
3681+
(entree: { readonly tool_response?: unknown; readonly tool_name?: string }) =>
3682+
Promise.resolve(
3683+
garderLaSortie(entree.tool_response, entree.tool_name) ?? { continue: true },
3684+
),
36833685
],
36843686
},
36853687
],

apps/server/src/provider/gardeDeSortieDOutil.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,28 @@ export interface SortieDeHook {
7171
* sortie sans raison ; rendre `null` laisse le SDK garder l'originale, à
7272
* l'octet près.
7373
*/
74-
export function garderLaSortie(sortie: unknown): SortieDeHook | null {
74+
/**
75+
* LES OUTILS QUI LISENT LE DISQUE LOCAL — on les SCANNE, on ne les RÉÉCRIT pas.
76+
*
77+
* Décision du 03/08, et c'est la moitié la plus importante du correctif.
78+
* Ce garde a été écrit pour les JOURNAUX : empêcher un secret d'atteindre une
79+
* trace, un export, une sortie qui part chez un tiers. On l'avait branché sur
80+
* TOUT ce qu'un agent reçoit, y compris la lecture de ses propres fichiers.
81+
*
82+
* Or caviarder un fichier local ne protège de RIEN : le secret est déjà sur la
83+
* machine où l'agent tourne, et il peut le relire autrement. En échange, ça
84+
* abîme la matière sur laquelle il travaille — mesuré : 800 fichiers du dépôt
85+
* altérés sur 15 255, 459 lignes perdues, sans qu'aucun ne contienne le moindre
86+
* secret. Un agent recevait du code dont les numéros de ligne ne collaient plus
87+
* au disque.
88+
*
89+
* Aucune expression régulière, si fine soit-elle, ne répare ça : c'est
90+
* l'ENDROIT qui était faux. Le scan de menaces, lui, reste actif sur tous les
91+
* outils — il ne modifie rien, il alerte.
92+
*/
93+
const OUTILS_DE_LECTURE_LOCALE = new Set(["Read", "Grep", "Glob", "NotebookRead"]);
94+
95+
export function garderLaSortie(sortie: unknown, nomDOutil?: string): SortieDeHook | null {
7596
const transformee = transformerSortie(sortie);
7697

7798
// On se fie aux NOTES, pas à une comparaison de valeurs : `transformerSortie`
@@ -89,12 +110,17 @@ export function garderLaSortie(sortie: unknown): SortieDeHook | null {
89110

90111
// Rien de caviardé et rien à dire : on ne touche pas, et le SDK garde
91112
// l'originale à l'octet près.
92-
if (!aCaviarde && alertes.length === 0) return null;
113+
// La lecture d'un fichier LOCAL n'est jamais réécrite : on garde l'alerte,
114+
// on rend l'original à l'octet près.
115+
const reecritureAutorisee = nomDOutil === undefined || !OUTILS_DE_LECTURE_LOCALE.has(nomDOutil);
116+
const reecrit = aCaviarde && reecritureAutorisee;
117+
118+
if (!reecrit && alertes.length === 0) return null;
93119

94120
return {
95121
hookSpecificOutput: {
96122
hookEventName: "PostToolUse",
97-
...(aCaviarde ? { updatedToolOutput: transformee.valeur } : {}),
123+
...(reecrit ? { updatedToolOutput: transformee.valeur } : {}),
98124
...(alertes.length > 0 ? { additionalContext: alertes.join(" ") } : {}),
99125
},
100126
};

apps/server/src/secrets/Caviarder.ts

Lines changed: 149 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
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. */
3134
export const SEUIL_MASQUAGE_TOTAL = 18;
3235
export 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

101124
const 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`. */
138163
const AFFECTATION_ENV =
139-
/\b([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s;&|]+))/gu;
164+
/\b([A-Za-z_][A-Za-z0-9_-]*)[^\S\r\n]*=[^\S\r\n]*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s;&|]+))/gu;
140165

141166
/** `"clé": "valeur"` en JSON, `clé: valeur` en YAML. */
142167
const AFFECTATION_OBJET =
143-
/(?:"([A-Za-z_][A-Za-z0-9_-]*)"|\b([A-Za-z_][A-Za-z0-9_-]*))\s*:\s*(?:"([^"\n]*)"|([^\s,}\n]+))/gu;
168+
/(?:"([A-Za-z_][A-Za-z0-9_-]*)"|\b([A-Za-z_][A-Za-z0-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 =
154179
const 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(authorization|proxy-authorization)\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(authorization|proxy-authorization)[^\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. */
160194
const PARAM_URL = /([?&])([A-Za-z_][A-Za-z0-9_-]*)=([^&\s"']+)/gu;
@@ -189,59 +223,129 @@ const CLE_PRIVEE = /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE
189223
*/
190224
export 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 = /^-----BEGIN[^-]*PRIVATE KEY-----/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

Comments
 (0)