Par Claude code Anthropic

📄 main.rs 🔒 87b7e56e…60750bb6 Se connecter pour télécharger ← Retour
// ⛔ INTERDIT : unwrap_or / unwrap_or_default / unwrap_or_else / unwrap / expect / anyhow / opérateur ? / await (sans match)
//
// detecteur-fuite-secrets-ia — détecte des secrets (clés API de formats
// connus + détection générique par entropie de Shannon) qui se
// retrouveraient accidentellement dans des prompts, réponses de modèle,
// ou journaux applicatifs liés à un agent IA. Stdlib uniquement, zéro
// dépendance externe, zéro appel réseau.
//
// Deux familles de détecteurs :
// 1. FORMATS CONNUS — préfixes/longueurs documentés publiquement (AWS,
//    GitHub, style OpenAI, Slack, clé privée PEM, jeton Bearer générique).
// 2. ENTROPIE DE SHANNON — sur une ligne d'affectation dont le nom de
//    variable évoque un secret (contient "key"/"secret"/"token"/
//    "password"/"credential"), calcule l'entropie de la valeur assignée ;
//    une chaîne aléatoire (vraie clé) a une entropie nettement plus
//    élevée qu'un mot ou une phrase — technique reconnue (même principe
//    que truffleHog/gitleaks), implémentée ici sans aucune dépendance :
//    H = -Σ p(c)·log2(p(c)) sur la distribution des caractères.
//
// Suivi de position caractère par caractère — même technique que les
// deux autres skills Rust de ce même projet cybersécurité IA.
//
// Substitution délibérée (même raison que les autres skills Rust du
// projet) : `eprintln!` remplace `tracing` — outil CLI ponctuel, pas un
// serveur long-running.

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

const DOSSIERS_IGNORES: &[&str] = &["target", ".git", "node_modules", "__pycache__", ".venv", "venv"];
const EXTENSIONS_SCANNEES: &[&str] =
    &["md", "markdown", "txt", "log", "json", "yaml", "yml", "rs", "py", "env"];
const MARQUEUR_EXEMPTION_LIGNE: &str = "secret-ok";

const ENTROPIE_SEUIL: f64 = 3.5;
const LONGUEUR_MIN_ENTROPIE: usize = 16;

/// Mots-clés dont la présence (insensible à la casse) dans un nom de
/// variable évoque un secret.
const MOTS_CLE_SECRET: &[&str] =
    &["key", "secret", "token", "password", "passwd", "credential", "api_key", "apikey"];

/// Valeurs manifestement des gabarits/placeholders, jamais un vrai
/// secret — exclues avant tout calcul d'entropie pour réduire le bruit.
const PLACEHOLDERS: &[&str] = &[
    "changeme", "your_api_key_here", "xxxxxxxx", "placeholder", "example",
    "todo", "fixme", "insert_key_here", "your-key", "process.env", "os.environ", "std::env",
];

#[derive(Debug, Clone, Copy)]
struct FormatConnu {
    prefixe: &'static str,
    nom: &'static str,
    longueur_suffixe: usize,
}

/// Formats de secrets documentés publiquement — préfixe + longueur de
/// suffixe alphanumérique attendue (validation légère, pas un vrai
/// contrôle de somme de contrôle propre à chaque fournisseur).
const FORMATS_CONNUS: &[FormatConnu] = &[
    FormatConnu { prefixe: "AKIA", nom: "AWS Access Key ID", longueur_suffixe: 16 },
    FormatConnu { prefixe: "ghp_", nom: "GitHub Personal Access Token", longueur_suffixe: 36 },
    FormatConnu { prefixe: "gho_", nom: "GitHub OAuth Token", longueur_suffixe: 36 },
    FormatConnu { prefixe: "ghs_", nom: "GitHub Server Token", longueur_suffixe: 36 },
    FormatConnu { prefixe: "sk-", nom: "Cle style OpenAI (sk-)", longueur_suffixe: 20 },
    FormatConnu { prefixe: "xoxb-", nom: "Slack Bot Token", longueur_suffixe: 10 },
    FormatConnu { prefixe: "xoxp-", nom: "Slack User Token", longueur_suffixe: 10 },
];

const MOTIF_PEM: &str = "PRIVATE KEY-----";
const MOTIF_BEARER: &str = "Bearer ";
const LONGUEUR_MIN_BEARER: usize = 20;

// ── Types ─────────────────────────────────────────────────────────────────

#[derive(Debug)]
enum Erreur {
    ArgumentManquant,
    DossierInvalide(String),
    LectureFichier { chemin: String, source: std::io::Error },
}

impl std::fmt::Display for Erreur {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Erreur::ArgumentManquant => write!(f, "argument dossier_cible manquant"),
            Erreur::DossierInvalide(chemin) => write!(f, "dossier invalide : {chemin}"),
            Erreur::LectureFichier { chemin, source } => {
                write!(f, "lecture de {chemin} echouee : {source}")
            }
        }
    }
}

#[derive(Debug, Clone)]
enum Categorie {
    FormatConnu { nom: String },
    ClePrivéePem,
    JetonBearer,
    EntropieElevee { variable: String, entropie: f64 },
}

#[derive(Debug, Clone)]
struct Trouvaille {
    fichier: String,
    ligne: usize,
    categorie: Categorie,
}

// ── Entropie de Shannon (fonction PURE) ─────────────────────────────────

fn entropie_shannon(s: &str) -> f64 {
    let longueur = s.chars().count();
    if longueur == 0 {
        return 0.0;
    }
    let mut frequences: HashMap<char, usize> = HashMap::new();
    for c in s.chars() {
        let compteur = frequences.entry(c).or_insert(0);
        *compteur += 1;
    }
    let mut entropie = 0.0f64;
    for compte in frequences.values() {
        let p = *compte as f64 / longueur as f64;
        entropie -= p * p.log2();
    }
    entropie
}

fn est_placeholder(valeur: &str) -> bool {
    let minuscule = valeur.to_lowercase();
    PLACEHOLDERS.iter().any(|p| minuscule.contains(p))
}

// ── Extraction affectation (fonction PURE) ──────────────────────────────

fn position_separateur(ligne: &str) -> Option<usize> {
    let pos_egal = ligne.find('=');
    let pos_deux_points = ligne.find(':');
    match (pos_egal, pos_deux_points) {
        (Some(a), Some(b)) => Some(a.min(b)),
        (Some(a), None) => Some(a),
        (None, Some(b)) => Some(b),
        (None, None) => None,
    }
}

fn extraire_nom_variable(ligne: &str, position_sep: usize) -> String {
    let gauche = ligne[..position_sep].trim();
    let nom = gauche
        .trim_start_matches("let mut ")
        .trim_start_matches("let ")
        .trim_matches('"')
        .trim_matches('\'')
        .trim();
    nom.to_string()
}

/// Fonction PURE — extrait la première valeur entre guillemets (simples
/// ou doubles) après le séparateur, si présente.
fn extraire_valeur_citee(ligne: &str, position_sep: usize) -> Option<String> {
    let reste = &ligne[position_sep + 1..];
    for guillemet in ['"', '\''] {
        // Combinateurs plutôt que if-let imbriqué (§26) — let-chains pas
        // encore stables sur ce toolchain (rustc 1.93, stabilisation 1.95).
        let trouve = reste.find(guillemet).and_then(|debut| {
            let apres = &reste[debut + guillemet.len_utf8()..];
            apres.find(guillemet).map(|fin| apres[..fin].to_string())
        });
        if trouve.is_some() {
            return trouve;
        }
    }
    None
}

// ── Détecteur formats connus (fonction PURE) ────────────────────────────

fn detecter_formats_connus(ligne: &str) -> Vec<(usize, String)> {
    let mut resultats = Vec::new();
    let octets: &[u8] = ligne.as_bytes();
    for format in FORMATS_CONNUS {
        let prefixe_octets = format.prefixe.as_bytes();
        let mut i = 0usize;
        while i + prefixe_octets.len() <= octets.len() {
            if &octets[i..i + prefixe_octets.len()] == prefixe_octets {
                let debut_suffixe = i + prefixe_octets.len();
                let fin_suffixe = (debut_suffixe + format.longueur_suffixe).min(octets.len());
                let suffixe = &ligne[debut_suffixe..fin_suffixe];
                let longueur_reelle = suffixe.chars().filter(|c| c.is_ascii_alphanumeric()).count();
                if longueur_reelle >= format.longueur_suffixe {
                    resultats.push((i, format.nom.to_string()));
                }
            }
            i += 1;
        }
    }
    resultats
}

fn detecter_pem(ligne: &str) -> Option<usize> {
    ligne.find(MOTIF_PEM)
}

fn detecter_bearer(ligne: &str) -> Option<usize> {
    let position = ligne.find(MOTIF_BEARER)?;
    let apres = &ligne[position + MOTIF_BEARER.len()..];
    let longueur_jeton = apres
        .chars()
        .take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
        .count();
    if longueur_jeton >= LONGUEUR_MIN_BEARER {
        Some(position)
    } else {
        None
    }
}

/// Fonction PURE — retourne (nom_variable, entropie) si la ligne est une
/// affectation dont le nom évoque un secret ET dont la valeur atteint le
/// seuil d'entropie. Structure entièrement en `match` séquentiel (pas de
/// `if let` imbriqué — évite l'avertissement §26 sans dépendre des
/// let-chains, pas encore stables sur ce toolchain — et pas d'opérateur
/// `?` sur `Option`, écarté par convention de ce projet même sur les
/// skills locaux, voir Consigne_a_pour_IA_claude.txt).
fn detecter_entropie_elevee(ligne: &str) -> Option<(String, f64)> {
    let position_sep = match position_separateur(ligne) {
        Some(p) => p,
        None => return None,
    };
    let nom_variable = extraire_nom_variable(ligne, position_sep);
    let nom_minuscule = nom_variable.to_lowercase();
    let evoque_secret = MOTS_CLE_SECRET.iter().any(|m| nom_minuscule.contains(m));
    if !evoque_secret {
        return None;
    }
    let valeur = match extraire_valeur_citee(ligne, position_sep) {
        Some(v) => v,
        None => return None,
    };
    let longueur = valeur.chars().count();
    if longueur < LONGUEUR_MIN_ENTROPIE || est_placeholder(&valeur) {
        return None;
    }
    let entropie = entropie_shannon(&valeur);
    if entropie < ENTROPIE_SEUIL {
        return None;
    }
    Some((nom_variable, entropie))
}

// ── Agrégation ────────────────────────────────────────────────────────────

fn analyser_contenu(fichier: &str, texte: &str) -> Vec<Trouvaille> {
    let mut trouvailles = Vec::new();

    for (numero_ligne, ligne) in texte.lines().enumerate() {
        if ligne.contains(MARQUEUR_EXEMPTION_LIGNE) {
            continue;
        }
        let ligne_reelle = numero_ligne + 1;

        for (_pos, nom) in detecter_formats_connus(ligne) {
            trouvailles.push(Trouvaille {
                fichier: fichier.to_string(),
                ligne: ligne_reelle,
                categorie: Categorie::FormatConnu { nom },
            });
        }

        if detecter_pem(ligne).is_some() {
            trouvailles.push(Trouvaille {
                fichier: fichier.to_string(),
                ligne: ligne_reelle,
                categorie: Categorie::ClePrivéePem,
            });
        }

        if detecter_bearer(ligne).is_some() {
            trouvailles.push(Trouvaille {
                fichier: fichier.to_string(),
                ligne: ligne_reelle,
                categorie: Categorie::JetonBearer,
            });
        }

        match detecter_entropie_elevee(ligne) {
            Some((nom_variable, entropie)) => {
                trouvailles.push(Trouvaille {
                    fichier: fichier.to_string(),
                    ligne: ligne_reelle,
                    categorie: Categorie::EntropieElevee { variable: nom_variable, entropie },
                });
            }
            None => {}
        }
    }

    trouvailles
}

// ── I/O et parcours de fichiers ──────────────────────────────────────────

fn scanner_fichier(chemin: &Path) -> Result<Vec<Trouvaille>, Erreur> {
    let contenu = match fs::read_to_string(chemin) {
        Ok(c) => c,
        Err(e) => {
            return Err(Erreur::LectureFichier { chemin: chemin.display().to_string(), source: e });
        }
    };
    Ok(analyser_contenu(&chemin.display().to_string(), &contenu))
}

fn extension_scannee(chemin: &Path) -> bool {
    match chemin.extension() {
        Some(ext) => match ext.to_str() {
            Some(s) => EXTENSIONS_SCANNEES.contains(&s.to_lowercase().as_str()),
            None => false, // §5-ok — extension non UTF-8 valide : jamais une extension scannee legitime
        },
        None => false, // §5-ok — pas d'extension du tout : jamais scanne
    }
}

fn iter_fichiers_cibles(racine: &Path, resultats: &mut Vec<PathBuf>) {
    let entrees = match fs::read_dir(racine) {
        Ok(e) => e,
        Err(e) => {
            eprintln!("Avertissement : lecture dossier {} echouee : {e}", racine.display());
            return;
        }
    };
    for entree_res in entrees {
        let entree = match entree_res {
            Ok(e) => e,
            Err(e) => {
                eprintln!("Avertissement : entree de dossier illisible : {e}");
                continue;
            }
        };
        let chemin = entree.path();
        let nom = match chemin.file_name() {
            Some(n) => match n.to_str() {
                Some(s) => s,
                None => continue,
            },
            None => continue,
        };
        if DOSSIERS_IGNORES.contains(&nom) {
            continue;
        }
        let type_fichier = match entree.file_type() {
            Ok(ft) => ft,
            Err(e) => {
                eprintln!("Avertissement : type de {} inconnu : {e}", chemin.display());
                continue; // §27-ok — intentionnel : ce fichier est ignore, le parcours des autres continue
            }
        };
        if type_fichier.is_dir() {
            iter_fichiers_cibles(&chemin, resultats);
        } else if extension_scannee(&chemin) {
            resultats.push(chemin);
        }
    }
}

fn decrire_categorie(categorie: &Categorie) -> String {
    match categorie {
        Categorie::FormatConnu { nom } => format!("FORMAT CONNU — {nom}"),
        Categorie::ClePrivéePem => "CLE PRIVEE PEM".to_string(),
        Categorie::JetonBearer => "JETON BEARER".to_string(),
        Categorie::EntropieElevee { variable, entropie } => {
            format!("ENTROPIE ELEVEE — variable `{variable}` (H={entropie:.2} bits/car.)")
        }
    }
}

fn generer_rapport(trouvailles: &[Trouvaille], nb_fichiers: usize) -> String {
    let mut rapport = String::new();
    rapport.push_str("# Rapport detecteur-fuite-secrets-ia\n\n");
    rapport.push_str(&format!(
        "{nb_fichiers} fichier(s) examine(s) — {} signalement(s) au total.\n\n",
        trouvailles.len()
    ));
    if trouvailles.is_empty() {
        rapport.push_str("Aucun secret probable detecte.\n");
        return rapport;
    }
    for t in trouvailles {
        rapport.push_str(&format!("- **{}:{}** — {}\n", t.fichier, t.ligne, decrire_categorie(&t.categorie)));
    }
    rapport
}

fn executer(dossier_cible: &str) -> Result<String, Erreur> {
    let racine = Path::new(dossier_cible);
    if !racine.is_dir() {
        return Err(Erreur::DossierInvalide(dossier_cible.to_string()));
    }
    let mut fichiers = Vec::new();
    iter_fichiers_cibles(racine, &mut fichiers);

    let mut toutes_trouvailles = Vec::new();
    for chemin in &fichiers {
        match scanner_fichier(chemin) {
            Ok(mut t) => toutes_trouvailles.append(&mut t),
            Err(e) => {
                eprintln!("Avertissement : {e}");
                // §27-ok — intentionnel : un fichier illisible est ignore, le scan des autres continue
            }
        }
    }
    Ok(generer_rapport(&toutes_trouvailles, fichiers.len()))
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let dossier_cible = match args.get(1) {
        Some(d) => d.clone(),
        None => {
            eprintln!("Erreur : {}", Erreur::ArgumentManquant);
            eprintln!("Usage : detecteur-fuite-secrets-ia <dossier_cible>");
            std::process::exit(1);
        }
    };
    match executer(&dossier_cible) {
        Ok(rapport) => println!("{rapport}"),
        Err(e) => {
            eprintln!("Erreur : {e}");
            std::process::exit(1); // §27-ok — std::process::exit diverge, aucune execution ne suit
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── Entropie ──────────────────────────────────────────────────────

    #[test]
    fn t_entropie_chaine_repetee_faible() {
        let e = entropie_shannon("aaaaaaaaaaaaaaaa");
        assert!(e < 0.1, "chaine repetee : entropie quasi nulle attendue, obtenu {e}");
    }

    #[test]
    fn t_entropie_chaine_aleatoire_elevee() {
        let e = entropie_shannon("aK9x2Qm7Zp4Rt8Ln5Wv1Bc6Yd3Fh0");
        assert!(e > 3.5, "chaine aleatoire : entropie elevee attendue, obtenu {e}");
    }

    #[test]
    fn t_entropie_chaine_vide_nulle() {
        assert_eq!(entropie_shannon(""), 0.0);
    }

    // ── Formats connus ────────────────────────────────────────────────

    #[test]
    fn t_detecte_aws_access_key() {
        let ligne = "aws_key = \"AKIAIOSFODNN7EXAMPLE\"";
        let resultats = detecter_formats_connus(ligne);
        assert_eq!(resultats.len(), 1);
        assert_eq!(resultats[0].1, "AWS Access Key ID");
    }

    #[test]
    fn t_detecte_github_token() {
        let ligne = "token = \"ghp_1234567890abcdefghij1234567890abcdef\"";
        let resultats = detecter_formats_connus(ligne);
        assert_eq!(resultats.len(), 1);
    }

    #[test]
    fn t_ignore_prefixe_seul_trop_court() {
        let ligne = "nom = \"AKIA\"";
        assert!(detecter_formats_connus(ligne).is_empty());
    }

    #[test]
    fn t_detecte_cle_pem() {
        let ligne = "-----BEGIN RSA PRIVATE KEY-----";
        assert!(detecter_pem(ligne).is_some());
    }

    #[test]
    fn t_detecte_bearer() {
        let ligne = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
        assert!(detecter_bearer(ligne).is_some());
    }

    #[test]
    fn t_ignore_bearer_trop_court() {
        let ligne = "Bearer abc";
        assert!(detecter_bearer(ligne).is_none());
    }

    // ── Extraction affectation ──────────────────────────────────────────

    #[test]
    fn t_extrait_variable_et_valeur() {
        let ligne = "api_key = \"valeur123\"";
        let pos = position_separateur(ligne);
        assert!(pos.is_some());
        let p = match pos {
            Some(v) => v,
            None => panic!("position attendue"),
        };
        assert_eq!(extraire_nom_variable(ligne, p), "api_key");
        assert_eq!(extraire_valeur_citee(ligne, p), Some("valeur123".to_string()));
    }

    #[test]
    fn t_extrait_variable_json() {
        let ligne = "\"api_key\": \"abcXYZ123\"";
        let pos = position_separateur(ligne);
        assert!(pos.is_some());
    }

    // ── Detection bout en bout : entropie ────────────────────────────

    #[test]
    fn t_detecte_secret_haute_entropie_nom_evocateur() {
        let texte = "api_secret = \"aK9x2Qm7Zp4Rt8Ln5Wv1Bc6Yd3Fh0Ku\"\n";
        let resultats = analyser_contenu("test.env", texte);
        assert_eq!(resultats.len(), 1);
    }

    #[test]
    fn t_ignore_nom_non_evocateur_meme_entropie_elevee() {
        let texte = "identifiant_public = \"aK9x2Qm7Zp4Rt8Ln5Wv1Bc6Yd3Fh0Ku\"\n";
        let resultats = analyser_contenu("test.env", texte);
        assert!(resultats.is_empty(), "nom de variable non evocateur d'un secret, aucun signalement attendu");
    }

    #[test]
    fn t_ignore_placeholder() {
        let texte = "api_key = \"YOUR_API_KEY_HERE_REPLACE_ME\"\n";
        let resultats = analyser_contenu("test.env", texte);
        assert!(resultats.is_empty(), "placeholder explicite, aucun signalement attendu");
    }

    #[test]
    fn t_ignore_valeur_courte() {
        let texte = "api_key = \"abc\"\n";
        let resultats = analyser_contenu("test.env", texte);
        assert!(resultats.is_empty(), "valeur trop courte pour un vrai secret");
    }

    #[test]
    fn t_ignore_reference_variable_environnement() {
        let texte = "api_key = \"process.env.API_KEY_VALUE_LONG_ENOUGH\"\n";
        let resultats = analyser_contenu("test.env", texte);
        assert!(resultats.is_empty(), "reference a une variable d'environnement, pas un secret en dur");
    }

    // ── Agregation + exemption ────────────────────────────────────────

    #[test]
    fn t_analyser_contenu_vide() {
        assert!(analyser_contenu("test.env", "").is_empty());
    }

    #[test]
    fn t_marqueur_exemption() {
        let texte = "aws_key = \"AKIAIOSFODNN7EXAMPLE\" // secret-ok\n";
        assert!(analyser_contenu("test.rs", texte).is_empty());
    }

    #[test]
    fn t_generer_rapport_vide() {
        let rapport = generer_rapport(&[], 5);
        assert!(rapport.contains("5 fichier"));
        assert!(rapport.contains("0 signalement"));
    }

    #[test]
    fn t_generer_rapport_avec_trouvaille() {
        let trouvailles = vec![Trouvaille {
            fichier: "a.env".to_string(),
            ligne: 2,
            categorie: Categorie::FormatConnu { nom: "AWS Access Key ID".to_string() },
        }];
        let rapport = generer_rapport(&trouvailles, 1);
        assert!(rapport.contains("a.env:2"));
        assert!(rapport.contains("AWS Access Key ID"));
    }

    #[test]
    fn t_extension_scannee() {
        assert!(extension_scannee(Path::new("f.env")));
        assert!(!extension_scannee(Path::new("f.exe")));
    }
}
21.4 Ko BLAKE3 : 87b7e56e…60750bb6