Skip to main content

mnemo/
secrets.rs

1//! Détection et redaction des secrets présents dans l'historique déjà stocké
2//! (`mnemo secrets scan` / `mnemo secrets redact`).
3//!
4//! Le filtrage à l'enregistrement ([`crate::filter::is_sensitive`]) empêche de
5//! stocker la plupart des commandes sensibles. Ce module traite l'historique
6//! **déjà présent** : il repère les commandes suspectes et peut les redacter en
7//! place, de façon non destructive (dry-run par défaut, sauvegarde obligatoire
8//! avant toute écriture).
9//!
10//! Garantie anti-fuite : la sortie utilisateur et la version redactée ne
11//! contiennent jamais la valeur sensible détectée. En cas de doute, la commande
12//! entière est remplacée par `[REDACTED COMMAND]`.
13//!
14//! La détection est volontairement heuristique : elle vise une protection
15//! raisonnable d'un historique shell, pas une exhaustivité parfaite.
16
17use anyhow::{Context, Result};
18
19use crate::db::SearchFilter;
20use crate::{backup, config, confirm, db, filter};
21
22/// Marqueur de valeur redactée.
23const REDACTED: &str = "[REDACTED]";
24/// Marqueur de commande entièrement redactée (non redactable proprement).
25const REDACTED_COMMAND: &str = "[REDACTED COMMAND]";
26/// Nombre d'exemples affichés en aperçu d'un dry-run.
27const PREVIEW_SAMPLES: usize = 5;
28
29/// Catégorie de détection d'un secret.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
31pub enum Category {
32    Password,
33    Token,
34    ApiKey,
35    BearerToken,
36    PrivateKey,
37    CredentialUrl,
38    EnvSecret,
39    UnknownSensitive,
40}
41
42impl Category {
43    /// Étiquette stable, utilisée à l'affichage et dans le JSON.
44    pub fn label(self) -> &'static str {
45        match self {
46            Category::Password => "password",
47            Category::Token => "token",
48            Category::ApiKey => "api_key",
49            Category::BearerToken => "bearer_token",
50            Category::PrivateKey => "private_key",
51            Category::CredentialUrl => "credential_url",
52            Category::EnvSecret => "env_secret",
53            Category::UnknownSensitive => "unknown_sensitive",
54        }
55    }
56}
57
58/// Résultat d'analyse d'une commande suspecte.
59#[derive(Debug, Clone)]
60pub struct Finding {
61    /// Catégories détectées (triées, dédupliquées).
62    pub categories: Vec<Category>,
63    /// Version redactée de la commande, garantie sans valeur sensible.
64    pub redacted: String,
65}
66
67impl Finding {
68    /// Catégories jointes par `,` pour l'affichage en colonne.
69    pub fn categories_label(&self) -> String {
70        self.categories
71            .iter()
72            .map(|c| c.label())
73            .collect::<Vec<_>>()
74            .join(",")
75    }
76}
77
78/// Indique si une commande a déjà été redactée (à ne pas retraiter).
79fn already_redacted(command: &str) -> bool {
80    command.contains(REDACTED) || command.contains(REDACTED_COMMAND)
81}
82
83/// Vrai si `c` peut composer un nom de variable d'environnement.
84fn is_name_char(c: char) -> bool {
85    c.is_ascii_alphanumeric() || c == '_'
86}
87
88/// Recherche insensible à la casse (ASCII) de `pat` dans `lc` à partir de `from`.
89/// `lc` doit être la version `to_ascii_lowercase` (caractère à caractère) de la
90/// commande, ce qui préserve l'alignement des indices.
91fn find_ci(lc: &[char], pat: &[char], from: usize) -> Option<usize> {
92    if pat.is_empty() || lc.len() < pat.len() {
93        return None;
94    }
95    let last = lc.len() - pat.len();
96    (from..=last).find(|&i| lc[i..i + pat.len()] == *pat)
97}
98
99/// Lit une valeur à partir de l'indice `start` : entre guillemets si la valeur
100/// est quotée, sinon jusqu'à la prochaine espace. Renvoie la valeur et l'indice
101/// suivant.
102fn read_value(chars: &[char], start: usize) -> (String, usize) {
103    let n = chars.len();
104    if start >= n {
105        return (String::new(), start);
106    }
107    let quote = chars[start];
108    if quote == '"' || quote == '\'' {
109        let mut j = start + 1;
110        let mut value = String::new();
111        while j < n && chars[j] != quote {
112            value.push(chars[j]);
113            j += 1;
114        }
115        let end = if j < n { j + 1 } else { j };
116        (value, end)
117    } else {
118        let mut j = start;
119        let mut value = String::new();
120        while j < n && !chars[j].is_whitespace() {
121            value.push(chars[j]);
122            j += 1;
123        }
124        (value, j)
125    }
126}
127
128/// Catégorie associée à un nom de variable, s'il est sensible.
129fn assignment_category(name: &str) -> Option<Category> {
130    let upper = name.to_ascii_uppercase();
131    if upper.contains("PASSWORD") || upper.contains("PASSWD") || upper == "PASS" {
132        Some(Category::Password)
133    } else if upper.contains("API_KEY") || upper.contains("APIKEY") {
134        Some(Category::ApiKey)
135    } else if upper.contains("TOKEN") {
136        Some(Category::Token)
137    } else if upper.contains("SECRET")
138        || upper.contains("ACCESS_KEY")
139        || upper.contains("PRIVATE_KEY")
140        || upper.contains("CREDENTIAL")
141    {
142        Some(Category::EnvSecret)
143    } else {
144        None
145    }
146}
147
148/// Détecte les affectations `NOM=valeur` à nom sensible (`PASSWORD=`, `TOKEN=`,
149/// `AWS_SECRET_ACCESS_KEY=`…). Gère les valeurs entre guillemets.
150fn scan_assignments(chars: &[char]) -> Vec<(Category, String)> {
151    let n = chars.len();
152    let mut out = Vec::new();
153    let mut i = 0;
154    while i < n {
155        if chars[i] == '=' {
156            let mut start = i;
157            while start > 0 && is_name_char(chars[start - 1]) {
158                start -= 1;
159            }
160            if start < i {
161                let name: String = chars[start..i].iter().collect();
162                if let Some(cat) = assignment_category(&name) {
163                    let (value, end) = read_value(chars, i + 1);
164                    if !value.is_empty() {
165                        out.push((cat, value));
166                    }
167                    i = end;
168                    continue;
169                }
170            }
171        }
172        i += 1;
173    }
174    out
175}
176
177/// Détecte les jetons `Authorization: Bearer <token>` (y compris dans `-H` /
178/// `--header`).
179fn scan_bearer(chars: &[char], lc: &[char]) -> Vec<String> {
180    let pat: Vec<char> = "bearer".chars().collect();
181    let n = chars.len();
182    let mut out = Vec::new();
183    let mut from = 0;
184    while let Some(pos) = find_ci(lc, &pat, from) {
185        let mut j = pos + pat.len();
186        // Au moins une espace entre "Bearer" et le jeton.
187        let mut saw_space = false;
188        while j < n && chars[j].is_whitespace() {
189            saw_space = true;
190            j += 1;
191        }
192        if saw_space && j < n {
193            let stop = chars[j];
194            if stop != '"' && stop != '\'' {
195                // Le jeton s'arrête à une espace ou à un guillemet fermant
196                // (cas d'un en-tête entièrement quoté).
197                let start = j;
198                while j < n && !chars[j].is_whitespace() && chars[j] != '"' && chars[j] != '\'' {
199                    j += 1;
200                }
201                let value: String = chars[start..j].iter().collect();
202                if !value.is_empty() {
203                    out.push(value);
204                }
205            }
206        }
207        from = pos + pat.len();
208    }
209    out
210}
211
212/// Détecte les URLs contenant des identifiants `scheme://user:password@host`.
213/// Seul le mot de passe (après le premier `:` de la partie identifiants) est
214/// renvoyé pour redaction.
215fn scan_credential_urls(chars: &[char], lc: &[char]) -> Vec<String> {
216    let pat: Vec<char> = "://".chars().collect();
217    let n = chars.len();
218    let mut out = Vec::new();
219    let mut from = 0;
220    while let Some(pos) = find_ci(lc, &pat, from) {
221        let userinfo_start = pos + pat.len();
222        let mut j = userinfo_start;
223        let mut at = None;
224        let mut colon = None;
225        while j < n {
226            let c = chars[j];
227            if c == '@' {
228                at = Some(j);
229                break;
230            }
231            if c == '/' || c.is_whitespace() {
232                break;
233            }
234            if c == ':' && colon.is_none() {
235                colon = Some(j);
236            }
237            j += 1;
238        }
239        if let (Some(at), Some(colon)) = (at, colon) {
240            if colon + 1 < at {
241                let password: String = chars[colon + 1..at].iter().collect();
242                if !password.is_empty() {
243                    out.push(password);
244                }
245            }
246        }
247        from = userinfo_start;
248    }
249    out
250}
251
252/// Options longues sensibles reconnues et leur catégorie.
253const CLI_OPTIONS: &[(&str, Category)] = &[
254    ("--password", Category::Password),
255    ("--pass", Category::Password),
256    ("--token", Category::Token),
257    ("--access-token", Category::Token),
258    ("--api-key", Category::ApiKey),
259    ("--api_key", Category::ApiKey),
260    ("--apikey", Category::ApiKey),
261    ("--secret", Category::EnvSecret),
262];
263
264/// Vrai si la position `i` est un début de mot (début de chaîne ou précédé d'une
265/// espace).
266fn at_word_start(chars: &[char], i: usize) -> bool {
267    i == 0 || chars[i - 1].is_whitespace()
268}
269
270/// Détecte les options CLI sensibles `--password X`, `--token=X`, `--api-key X`.
271fn scan_cli_options(chars: &[char], lc: &[char]) -> Vec<(Category, String)> {
272    let n = chars.len();
273    let mut out = Vec::new();
274    for (opt, cat) in CLI_OPTIONS {
275        let pat: Vec<char> = opt.chars().collect();
276        let mut from = 0;
277        while let Some(pos) = find_ci(lc, &pat, from) {
278            from = pos + pat.len();
279            if !at_word_start(chars, pos) {
280                continue;
281            }
282            let after = pos + pat.len();
283            if after < n && chars[after] == '=' {
284                let (value, _) = read_value(chars, after + 1);
285                if !value.is_empty() {
286                    out.push((*cat, value));
287                }
288            } else if after < n && chars[after].is_whitespace() {
289                let mut j = after;
290                while j < n && chars[j].is_whitespace() {
291                    j += 1;
292                }
293                // La valeur ne doit pas être une autre option.
294                if j < n && chars[j] != '-' {
295                    let (value, _) = read_value(chars, j);
296                    if !value.is_empty() {
297                        out.push((*cat, value));
298                    }
299                }
300            }
301        }
302    }
303    out
304}
305
306/// Clients de base de données acceptant un mot de passe attaché `-p<valeur>`.
307fn mentions_db_client(lc: &[char]) -> bool {
308    for client in ["mysql", "mariadb", "psql", "mysqldump"] {
309        let pat: Vec<char> = client.chars().collect();
310        if find_ci(lc, &pat, 0).is_some() {
311            return true;
312        }
313    }
314    false
315}
316
317/// Détecte le mot de passe attaché des clients SQL (`mysql -u root -p<valeur>`).
318/// Volontairement restreint à ces clients pour éviter les faux positifs (`-p`
319/// est courant ailleurs, par exemple `mkdir -p`).
320fn scan_db_password(chars: &[char], lc: &[char]) -> Vec<String> {
321    if !mentions_db_client(lc) {
322        return Vec::new();
323    }
324    let n = chars.len();
325    let mut out = Vec::new();
326    let mut i = 0;
327    while i + 1 < n {
328        if chars[i] == '-' && chars[i + 1] == 'p' && at_word_start(chars, i) {
329            let after = i + 2;
330            // Valeur attachée uniquement (`-pVALUE`), non vide et non une option.
331            if after < n && !chars[after].is_whitespace() && chars[after] != '-' {
332                let (value, end) = read_value(chars, after);
333                if !value.is_empty() {
334                    out.push(value);
335                }
336                i = end;
337                continue;
338            }
339        }
340        i += 1;
341    }
342    out
343}
344
345/// Détecte un fragment de clé privée embarqué.
346fn contains_private_key(command: &str) -> bool {
347    command.contains("PRIVATE KEY-----")
348}
349
350/// Déduplique une liste de catégories en préservant un ordre stable (tri).
351fn dedup_sorted(mut cats: Vec<Category>) -> Vec<Category> {
352    cats.sort();
353    cats.dedup();
354    cats
355}
356
357/// Analyse une commande et renvoie un [`Finding`] si elle paraît sensible.
358///
359/// `keywords` correspond à `config.sensitive_keywords` et alimente la détection
360/// de repli `unknown_sensitive`, réutilisant la logique d'enregistrement.
361pub fn analyze(command: &str, keywords: &[String]) -> Option<Finding> {
362    if command.trim().is_empty() || already_redacted(command) {
363        return None;
364    }
365
366    let chars: Vec<char> = command.chars().collect();
367    let lc: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect();
368
369    let mut categories: Vec<Category> = Vec::new();
370    let mut values: Vec<String> = Vec::new();
371    let mut force_full = false;
372
373    if contains_private_key(command) {
374        categories.push(Category::PrivateKey);
375        force_full = true;
376    }
377    for value in scan_bearer(&chars, &lc) {
378        categories.push(Category::BearerToken);
379        values.push(value);
380    }
381    for value in scan_credential_urls(&chars, &lc) {
382        categories.push(Category::CredentialUrl);
383        values.push(value);
384    }
385    for (cat, value) in scan_assignments(&chars) {
386        categories.push(cat);
387        values.push(value);
388    }
389    for (cat, value) in scan_cli_options(&chars, &lc) {
390        categories.push(cat);
391        values.push(value);
392    }
393    for value in scan_db_password(&chars, &lc) {
394        categories.push(Category::Password);
395        values.push(value);
396    }
397
398    // Repli : aucune structure reconnue mais un mot-clé sensible est présent.
399    if categories.is_empty() && filter::is_sensitive(command, keywords) {
400        categories.push(Category::UnknownSensitive);
401        force_full = true;
402    }
403
404    if categories.is_empty() {
405        return None;
406    }
407
408    let redacted = build_redacted(command, &values, force_full);
409
410    Some(Finding {
411        categories: dedup_sorted(categories),
412        redacted,
413    })
414}
415
416/// Construit la version redactée d'une commande.
417///
418/// Remplace chaque valeur sensible par `[REDACTED]`. Si une redaction propre est
419/// impossible (clé privée, repli `unknown_sensitive`, ou valeur résiduelle après
420/// remplacement), la commande entière devient `[REDACTED COMMAND]`. Cette
421/// fonction garantit que la sortie ne contient aucune valeur détectée.
422fn build_redacted(command: &str, values: &[String], force_full: bool) -> String {
423    if force_full || values.iter().all(|v| v.is_empty()) {
424        return REDACTED_COMMAND.to_string();
425    }
426
427    let mut redacted = command.to_string();
428    for value in values {
429        if !value.is_empty() {
430            redacted = redacted.replace(value.as_str(), REDACTED);
431        }
432    }
433
434    // Garantie anti-fuite : aucune valeur détectée ne doit subsister.
435    if values
436        .iter()
437        .any(|v| !v.is_empty() && redacted.contains(v.as_str()))
438    {
439        return REDACTED_COMMAND.to_string();
440    }
441    redacted
442}
443
444/// Une entrée suspecte prête à l'affichage ou à la redaction.
445struct ScanResult {
446    id: i64,
447    created_at: String,
448    finding: Finding,
449}
450
451/// Scanne la base et renvoie les commandes suspectes dont la redaction
452/// modifierait réellement le texte (les commandes déjà redactées sont ignorées).
453fn collect(conn: &rusqlite::Connection, keywords: &[String]) -> Result<(usize, Vec<ScanResult>)> {
454    let records = db::all_commands(conn, &SearchFilter::default())?;
455    let scanned = records.len();
456    let mut results = Vec::new();
457    for record in records {
458        if let Some(finding) = analyze(&record.command, keywords) {
459            if finding.redacted != record.command {
460                results.push(ScanResult {
461                    id: record.id,
462                    created_at: record.created_at,
463                    finding,
464                });
465            }
466        }
467    }
468    // Ordre déterministe : par identifiant croissant.
469    results.sort_by_key(|r| r.id);
470    Ok((scanned, results))
471}
472
473/// `mnemo secrets scan` : repère les commandes sensibles sans rien modifier.
474pub fn run_scan(limit: Option<usize>, json: bool) -> Result<()> {
475    let cfg = config::Config::load()?;
476    let conn = db::open(&config::db_path()?)?;
477    let (_scanned, results) = collect(&conn, &cfg.sensitive_keywords)?;
478
479    let shown: &[ScanResult] = match limit {
480        Some(n) => &results[..n.min(results.len())],
481        None => &results,
482    };
483
484    if json {
485        let items: Vec<_> = shown
486            .iter()
487            .map(|r| {
488                serde_json::json!({
489                    "id": r.id,
490                    "created_at": r.created_at,
491                    "categories": r.finding.categories.iter().map(|c| c.label()).collect::<Vec<_>>(),
492                    "redacted_command": r.finding.redacted,
493                })
494            })
495            .collect();
496        let value = serde_json::json!({
497            "suspected": results.len(),
498            "shown": shown.len(),
499            "results": items,
500        });
501        println!("{}", serde_json::to_string_pretty(&value)?);
502        return Ok(());
503    }
504
505    if results.is_empty() {
506        println!("Aucune commande potentiellement sensible détectée.");
507        return Ok(());
508    }
509
510    println!(
511        "Commandes potentiellement sensibles détectées : {}",
512        results.len()
513    );
514    println!();
515    println!("{:<6}  {:<19}  {:<20}  COMMANDE", "ID", "DATE", "TYPE");
516    for r in shown {
517        let date = r.created_at.get(0..19).unwrap_or(&r.created_at);
518        println!(
519            "{:<6}  {:<19}  {:<20}  {}",
520            r.id,
521            date,
522            r.finding.categories_label(),
523            r.finding.redacted
524        );
525    }
526    if shown.len() < results.len() {
527        println!();
528        println!(
529            "... {} résultat(s) supplémentaire(s) masqué(s) (utilisez --limit).",
530            results.len() - shown.len()
531        );
532    }
533    println!();
534    println!("Aucune modification effectuée. Pour nettoyer : mnemo secrets redact --apply.");
535    Ok(())
536}
537
538/// `mnemo secrets redact` : redacte les commandes sensibles déjà stockées.
539///
540/// Dry-run par défaut. `--apply` applique réellement, après une sauvegarde
541/// obligatoire et (sauf `--yes`) une confirmation. En mode non interactif sans
542/// `--yes`, l'application est refusée proprement.
543pub fn run_redact(dry_run: bool, apply: bool, assume_yes: bool, _backup: bool) -> Result<()> {
544    let _ = dry_run; // dry-run est le défaut ; le drapeau est explicite.
545    let cfg = config::Config::load()?;
546    let conn = db::open(&config::db_path()?)?;
547    let (scanned, results) = collect(&conn, &cfg.sensitive_keywords)?;
548
549    println!("Commandes scannées   : {scanned}");
550    println!("Commandes suspectes  : {}", results.len());
551
552    if results.is_empty() {
553        println!("Rien à redacter.");
554        return Ok(());
555    }
556
557    let preview = results.len().min(PREVIEW_SAMPLES);
558    println!("Aperçu :");
559    for r in &results[..preview] {
560        println!(
561            "  {:<6}  {:<20}  {}",
562            r.id,
563            r.finding.categories_label(),
564            r.finding.redacted
565        );
566    }
567    if results.len() > preview {
568        println!("  ... et {} autre(s).", results.len() - preview);
569    }
570
571    if !apply {
572        println!();
573        println!("[dry-run] Aucune modification effectuée. Relancez avec --apply pour redacter.");
574        return Ok(());
575    }
576
577    if !confirm::confirm(
578        &format!("Redacter {} commande(s) suspecte(s) ?", results.len()),
579        assume_yes,
580    )? {
581        println!("Redaction annulée.");
582        return Ok(());
583    }
584
585    // Sauvegarde obligatoire avant toute écriture : en cas d'échec, on n'applique
586    // rien.
587    let safety = backup::create_backup(None)
588        .context("sauvegarde préalable impossible : redaction annulée")?;
589    println!("Sauvegarde automatique : {}", safety.path.display());
590
591    let items: Vec<(i64, String)> = results
592        .iter()
593        .map(|r| (r.id, r.finding.redacted.clone()))
594        .collect();
595    let modified = db::apply_redactions(&conn, &items)?;
596
597    println!();
598    println!("Résumé :");
599    println!("  Commandes scannées : {scanned}");
600    println!("  Commandes suspectes: {}", results.len());
601    println!("  Commandes modifiées: {modified}");
602    println!("  Sauvegarde         : {}", safety.path.display());
603    Ok(())
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    fn kw() -> Vec<String> {
611        [
612            "password",
613            "token",
614            "secret",
615            "api_key",
616            "bearer",
617            "private_key",
618        ]
619        .iter()
620        .map(|s| s.to_string())
621        .collect()
622    }
623
624    /// Vérifie qu'aucune valeur sensible ne subsiste dans la version redactée.
625    fn assert_no_leak(redacted: &str, secrets: &[&str]) {
626        for s in secrets {
627            assert!(
628                !redacted.contains(s),
629                "fuite du secret {s:?} dans {redacted:?}"
630            );
631        }
632    }
633
634    #[test]
635    fn detecte_bearer_token() {
636        let f = analyze(
637            "curl -H \"Authorization: Bearer abcdef123456\" https://api.example.com",
638            &kw(),
639        )
640        .unwrap();
641        assert!(f.categories.contains(&Category::BearerToken));
642        assert_eq!(
643            f.redacted,
644            "curl -H \"Authorization: Bearer [REDACTED]\" https://api.example.com"
645        );
646        assert_no_leak(&f.redacted, &["abcdef123456"]);
647    }
648
649    #[test]
650    fn detecte_affectation_password() {
651        let f = analyze("export DB_PASSWORD=s3cr3t", &kw()).unwrap();
652        assert!(f.categories.contains(&Category::Password));
653        assert_eq!(f.redacted, "export DB_PASSWORD=[REDACTED]");
654        assert_no_leak(&f.redacted, &["s3cr3t"]);
655    }
656
657    #[test]
658    fn detecte_aws_secret_access_key() {
659        let f = analyze("export AWS_SECRET_ACCESS_KEY=abcd1234", &kw()).unwrap();
660        assert!(f.categories.contains(&Category::EnvSecret));
661        assert_eq!(f.redacted, "export AWS_SECRET_ACCESS_KEY=[REDACTED]");
662        assert_no_leak(&f.redacted, &["abcd1234"]);
663    }
664
665    #[test]
666    fn detecte_url_avec_identifiants() {
667        let f = analyze(
668            "git clone https://user:p4ssw0rd@example.com/repo.git",
669            &kw(),
670        )
671        .unwrap();
672        assert!(f.categories.contains(&Category::CredentialUrl));
673        assert_eq!(
674            f.redacted,
675            "git clone https://user:[REDACTED]@example.com/repo.git"
676        );
677        assert_no_leak(&f.redacted, &["p4ssw0rd"]);
678    }
679
680    #[test]
681    fn detecte_option_cli_password() {
682        let f = analyze("kubectl login --password hunter2 --user admin", &kw()).unwrap();
683        assert!(f.categories.contains(&Category::Password));
684        assert_eq!(
685            f.redacted,
686            "kubectl login --password [REDACTED] --user admin"
687        );
688        assert_no_leak(&f.redacted, &["hunter2"]);
689    }
690
691    #[test]
692    fn detecte_option_cli_avec_egal() {
693        let f = analyze("tool --token=abc123xyz", &kw()).unwrap();
694        assert!(f.categories.contains(&Category::Token));
695        assert_eq!(f.redacted, "tool --token=[REDACTED]");
696        assert_no_leak(&f.redacted, &["abc123xyz"]);
697    }
698
699    #[test]
700    fn detecte_mot_de_passe_mysql_attache() {
701        let f = analyze("mysql -u root -psup3rs3cret", &kw()).unwrap();
702        assert!(f.categories.contains(&Category::Password));
703        assert_eq!(f.redacted, "mysql -u root -p[REDACTED]");
704        assert_no_leak(&f.redacted, &["sup3rs3cret"]);
705    }
706
707    #[test]
708    fn detecte_fragment_cle_privee() {
709        let f = analyze("echo \"-----BEGIN OPENSSH PRIVATE KEY-----\" > id", &kw()).unwrap();
710        assert!(f.categories.contains(&Category::PrivateKey));
711        assert_eq!(f.redacted, "[REDACTED COMMAND]");
712    }
713
714    #[test]
715    fn commande_non_sensible_inchangee() {
716        assert!(analyze("ls -la", &kw()).is_none());
717        assert!(analyze("git commit -m 'fix'", &kw()).is_none());
718        assert!(analyze("mkdir -p build/out", &kw()).is_none());
719    }
720
721    #[test]
722    fn commande_deja_redactee_inchangee() {
723        assert!(analyze("export DB_PASSWORD=[REDACTED]", &kw()).is_none());
724        assert!(analyze("[REDACTED COMMAND]", &kw()).is_none());
725    }
726
727    #[test]
728    fn repli_mot_cle_redacte_toute_la_commande() {
729        // "sshpass" n'a pas de structure reconnue mais est un mot-clé sensible.
730        let f = analyze("sshpass -e ssh host", &["sshpass".to_string()]).unwrap();
731        assert_eq!(f.categories, vec![Category::UnknownSensitive]);
732        assert_eq!(f.redacted, "[REDACTED COMMAND]");
733    }
734
735    #[test]
736    fn valeur_quotee_avec_espace() {
737        let f = analyze("env PASSWORD=\"a b c\" run", &kw()).unwrap();
738        assert_eq!(f.redacted, "env PASSWORD=\"[REDACTED]\" run");
739        assert_no_leak(&f.redacted, &["a b c"]);
740    }
741}