Par Claude code Anthropic

📄 main.rs 🔒 8326bdf5…58f4ed51 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-sinks-llm-dangereux — détecte les points où une sortie de LLM
// (heuristique par motif de variable/appel, pas une vraie analyse de flot
// de données) atteint un point d'exécution dangereux (commande shell,
// évaluation dynamique, écriture de fichier, requête SQL) SANS qu'un appel
// de validation/nettoyage identifiable n'intervienne entre les deux, dans
// du code source Rust ou Python. Stdlib uniquement, zéro dépendance
// externe, zéro appel réseau.
//
// CONCEPTION DÉLIBÉRÉMENT SIMPLIFIÉE (voir SKILL.md "Limites connues") :
// corrélation par IDENTIFIANT TEXTUEL dans une FENÊTRE DE LIGNES bornée
// après la ligne source (pas un vrai suivi de portée de fonction/bloc,
// contrairement à detecteur-toctou-fichiers qui suit les accolades Rust —
// Python n'a pas d'accolades, une analyse de bloc unifiée aurait demandé
// deux implémentations complètement séparées pour un gain de précision
// marginal par rapport à une fenêtre bornée honnêtement documentée).
//
// Masquage chaînes/commentaires PROPRE À CHAQUE LANGAGE (Rust : //, /* */,
// "...", r#"..."# ; Python : #, '...', "...", '''...''', \"\"\"...\"\"\").
//
// 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::fs;
use std::path::{Path, PathBuf};

const DOSSIERS_IGNORES: &[&str] = &["target", ".git", "node_modules", "__pycache__", ".venv", "venv"];
const MARQUEUR_EXEMPTION_LIGNE: &str = "sink-ok";
const FENETRE_LIGNES_MAX: usize = 25;

/// Motifs indiquant qu'une ligne d'affectation capture probablement une
/// sortie de modèle de langage — heuristique par FORME D'APPEL
/// uniquement (chaque motif se termine par `(`), jamais par un nom de
/// variable nu : un identifiant comme "reponse_llm" réapparaît
/// naturellement sur sa propre ligne de réaffectation après validation
/// ("reponse_llm = sanitize(reponse_llm)"), ce qui créerait un second
/// point source non protégé si le nom seul suffisait à déclencher une
/// détection — bug réel trouvé par test unitaire (t_ignore_si_validation_
/// presente) avant publication, corrigé en restreignant aux formes
/// d'appel de fonction.
const MOTIFS_SOURCE_LLM: &[&str] = &[
    ".generate(", ".complete(", ".chat_completion(", ".chat(", ".invoke(", ".ask(",
    "query_llm(", "appeler_llm(", "interroger_modele(", "generer_reponse_ia(",
];

/// Mots-clés dont la présence entre la source et le puits, sur une ligne
/// contenant l'identifiant tainté, est traitée comme une neutralisation
/// du risque (heuristique — la présence du mot-clé n'est pas vérifiée
/// pour son EFFICACITÉ réelle, seulement pour sa présence).
const MOTIFS_VALIDATION: &[&str] = &[
    "valider_", "sanitize", "assainir_", "echapper_", "escape_",
    "verifier_", "nettoyer_", "validate(", "quote(", "shlex.quote",
    "shell_escape",
];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Langage {
    Rust,
    Python,
}

#[derive(Debug, Clone, Copy)]
struct MotifSink {
    motif: &'static str,
    categorie: &'static str,
    langage: Langage,
}

const SINKS: &[MotifSink] = &[
    MotifSink { motif: "Command::new(", categorie: "Commande shell", langage: Langage::Rust },
    MotifSink { motif: "std::process::Command::new(", categorie: "Commande shell", langage: Langage::Rust },
    MotifSink { motif: "subprocess.run(", categorie: "Commande shell", langage: Langage::Python },
    MotifSink { motif: "subprocess.call(", categorie: "Commande shell", langage: Langage::Python },
    MotifSink { motif: "subprocess.Popen(", categorie: "Commande shell", langage: Langage::Python },
    MotifSink { motif: "os.system(", categorie: "Commande shell", langage: Langage::Python },
    MotifSink { motif: "os.popen(", categorie: "Commande shell", langage: Langage::Python },
    MotifSink { motif: "eval(", categorie: "Evaluation dynamique", langage: Langage::Python },
    MotifSink { motif: "exec(", categorie: "Evaluation dynamique", langage: Langage::Python },
    MotifSink { motif: "File::create(", categorie: "Chemin de fichier", langage: Langage::Rust },
    MotifSink { motif: "fs::write(", categorie: "Chemin de fichier", langage: Langage::Rust },
    MotifSink { motif: "open(", categorie: "Chemin de fichier", langage: Langage::Python },
    MotifSink { motif: "cursor.execute(", categorie: "Requete SQL", langage: Langage::Python },
    MotifSink { motif: "sqlx::query(", categorie: "Requete SQL", langage: Langage::Rust },
    MotifSink { motif: ".execute(", categorie: "Requete SQL", langage: Langage::Rust },
];

// ── 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)]
struct Trouvaille {
    fichier: String,
    ligne_source: usize,
    ligne_sink: usize,
    identifiant: String,
    categorie: String,
    extrait_source: String,
    extrait_sink: String,
}

// ── Masquage chaînes/commentaires — UNE implémentation par langage ────────

/// Fonction PURE — masque Rust : exclut `//`, `/* */`, `"..."`, `r#"..."#`.
fn construire_masque_rust(caracteres: &[char]) -> Vec<bool> {
    let mut masque = vec![true; caracteres.len()];
    let mut i = 0usize;
    while i < caracteres.len() {
        if i + 1 < caracteres.len() && caracteres[i] == '/' && caracteres[i + 1] == '/' {
            let debut = i;
            while i < caracteres.len() && caracteres[i] != '\n' {
                i += 1;
            }
            for m in masque.iter_mut().take(i).skip(debut) {
                *m = false;
            }
            continue;
        }
        if i + 1 < caracteres.len() && caracteres[i] == '/' && caracteres[i + 1] == '*' {
            let debut = i;
            i += 2;
            while i + 1 < caracteres.len() && !(caracteres[i] == '*' && caracteres[i + 1] == '/') {
                i += 1;
            }
            i = (i + 2).min(caracteres.len());
            for m in masque.iter_mut().take(i).skip(debut) {
                *m = false;
            }
            continue;
        }
        if caracteres[i] == 'r' && i + 1 < caracteres.len() && (caracteres[i + 1] == '"' || caracteres[i + 1] == '#') {
            let debut = i;
            let mut j = i + 1;
            let mut dieses = 0usize;
            while j < caracteres.len() && caracteres[j] == '#' {
                dieses += 1;
                j += 1;
            }
            if j < caracteres.len() && caracteres[j] == '"' {
                j += 1;
                loop {
                    if j >= caracteres.len() {
                        break;
                    }
                    if caracteres[j] == '"' {
                        let mut compte = 0usize;
                        let mut k = j + 1;
                        while k < caracteres.len() && caracteres[k] == '#' && compte < dieses {
                            compte += 1;
                            k += 1;
                        }
                        if compte == dieses {
                            j = k;
                            break;
                        }
                    }
                    j += 1;
                }
                for m in masque.iter_mut().take(j.min(caracteres.len())).skip(debut) {
                    *m = false;
                }
                i = j;
                continue;
            }
        }
        if caracteres[i] == '"' {
            let debut = i;
            let mut j = i + 1;
            while j < caracteres.len() {
                if caracteres[j] == '\\' {
                    j += 2;
                    continue;
                }
                if caracteres[j] == '"' {
                    j += 1;
                    break;
                }
                j += 1;
            }
            for m in masque.iter_mut().take(j.min(caracteres.len())).skip(debut) {
                *m = false;
            }
            i = j;
            continue;
        }
        i += 1;
    }
    masque
}

/// Fonction PURE — masque Python : exclut `#`, `'...'`, `"..."`,
/// `'''...'''`, `"""..."""`.
fn construire_masque_python(caracteres: &[char]) -> Vec<bool> {
    let mut masque = vec![true; caracteres.len()];
    let mut i = 0usize;
    while i < caracteres.len() {
        if caracteres[i] == '#' {
            let debut = i;
            while i < caracteres.len() && caracteres[i] != '\n' {
                i += 1;
            }
            for m in masque.iter_mut().take(i).skip(debut) {
                *m = false;
            }
            continue;
        }
        for guillemet in ['\'', '"'] {
            if caracteres[i] != guillemet {
                continue;
            }
            let triple = i + 2 < caracteres.len()
                && caracteres[i + 1] == guillemet
                && caracteres[i + 2] == guillemet;
            let debut = i;
            if triple {
                let mut j = i + 3;
                while j + 2 < caracteres.len()
                    && !(caracteres[j] == guillemet && caracteres[j + 1] == guillemet && caracteres[j + 2] == guillemet)
                {
                    j += 1;
                }
                j = (j + 3).min(caracteres.len());
                for m in masque.iter_mut().take(j).skip(debut) {
                    *m = false;
                }
                i = j;
            } else {
                let mut j = i + 1;
                while j < caracteres.len() {
                    if caracteres[j] == '\\' {
                        j += 2;
                        continue;
                    }
                    if caracteres[j] == guillemet {
                        j += 1;
                        break;
                    }
                    j += 1;
                }
                for m in masque.iter_mut().take(j.min(caracteres.len())).skip(debut) {
                    *m = false;
                }
                i = j;
            }
            break;
        }
        if masque.get(i).copied() == Some(true) {
            i += 1;
        }
    }
    masque
}

fn langage_de_extension(chemin: &Path) -> Option<Langage> {
    match chemin.extension() {
        Some(ext) => match ext.to_str() {
            Some("rs") => Some(Langage::Rust),
            Some("py") => Some(Langage::Python),
            _ => None, // §5-ok — extension non reconnue ou non-UTF8 : jamais une cible de scan
        },
        None => None, // §5-ok — pas d'extension : jamais une cible de scan
    }
}

// ── Détection ligne par ligne ──────────────────────────────────────────

/// Fonction PURE — vrai si `ligne` contient `motif` à une position dont
/// TOUS les caractères sont marqués "code réel" par le masque.
fn ligne_contient_hors_chaine(ligne_masquee: &[(char, bool)], motif: &str) -> bool {
    let motif_car: Vec<char> = motif.chars().collect();
    if motif_car.is_empty() || motif_car.len() > ligne_masquee.len() {
        return false;
    }
    for depart in 0..=(ligne_masquee.len() - motif_car.len()) {
        let mut correspond = true;
        for (k, mc) in motif_car.iter().enumerate() {
            let (c, reel) = ligne_masquee[depart + k];
            if !reel || c != *mc {
                correspond = false;
                break;
            }
        }
        if correspond {
            return true;
        }
    }
    false
}

/// Fonction PURE — vrai si `identifiant` apparaît dans `ligne` comme un
/// MOT ENTIER (ni précédé ni suivi d'un caractère alphanumérique/`_`).
/// Sans cette vérification, un identifiant comme "reponse" correspond à
/// tort à une occurrence de "reponse2" (sous-chaîne) — bug réel trouvé
/// par test manuel avant publication (2026-08-19) : une ligne de
/// validation sur `reponse2` neutralisait silencieusement un
/// signalement réel sur `reponse`, un faux négatif — le pire cas pour un
/// outil de sécurité, corrigé par cette vérification de frontière de mot.
fn contient_identifiant_mot_entier(ligne: &str, identifiant: &str) -> bool {
    let car_ligne: Vec<char> = ligne.chars().collect();
    let car_id: Vec<char> = identifiant.chars().collect();
    if car_id.is_empty() || car_id.len() > car_ligne.len() {
        return false;
    }
    for depart in 0..=(car_ligne.len() - car_id.len()) {
        if car_ligne[depart..depart + car_id.len()] != car_id[..] {
            continue;
        }
        let avant_ok = depart == 0
            || !(car_ligne[depart - 1].is_alphanumeric() || car_ligne[depart - 1] == '_');
        let fin = depart + car_id.len();
        let apres_ok = fin == car_ligne.len()
            || !(car_ligne[fin].is_alphanumeric() || car_ligne[fin] == '_');
        if avant_ok && apres_ok {
            return true;
        }
    }
    false
}

/// Fonction PURE — extrait, sur une ligne, l'identifiant à gauche d'un
/// `=` d'affectation (ignore `==`), si la ligne correspond au patron
/// `[let [mut]] IDENT = ...` (Rust) ou `IDENT = ...` (Python).
fn extraire_identifiant_affectation(ligne: &str) -> Option<String> {
    let position_egal = ligne.find('=')?;
    if ligne.as_bytes().get(position_egal + 1) == Some(&b'=') {
        return None; // §5-ok — "==" n'est jamais une affectation
    }
    if position_egal > 0 && matches!(ligne.as_bytes().get(position_egal - 1), Some(b'!' | b'<' | b'>')) {
        return None; // §5-ok — !=, <=, >= ne sont jamais une affectation
    }
    let gauche = ligne[..position_egal].trim();
    let mot = gauche
        .trim_start_matches("let mut ")
        .trim_start_matches("let ")
        .trim();
    if mot.is_empty() || !mot.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
        return None;
    }
    Some(mot.to_string())
}

/// Fonction PURE — cœur de la détection pour UN langage donné.
fn analyser_contenu(fichier: &str, texte: &str, langage: Langage) -> Vec<Trouvaille> {
    let caracteres: Vec<char> = texte.chars().collect();
    let masque = match langage {
        Langage::Rust => construire_masque_rust(&caracteres),
        Langage::Python => construire_masque_python(&caracteres),
    };
    let lignes_brutes: Vec<&str> = texte.lines().collect();

    // Reconstruit, pour chaque ligne, la paire (caractère, est_code_reel).
    let mut lignes_masquees: Vec<Vec<(char, bool)>> = Vec::with_capacity(lignes_brutes.len());
    let mut curseur = 0usize;
    for ligne in &lignes_brutes {
        let longueur = ligne.chars().count();
        let mut paire = Vec::with_capacity(longueur);
        for k in 0..longueur {
            let idx = curseur + k;
            // hors bornes = ne devrait jamais arriver (longueur/curseur
            // restent cohérents avec caracteres par construction), mais
            // traité explicitement plutôt que suppose : valeurs neutres
            // sans jamais paniquer.
            let reel = match masque.get(idx) {
                Some(v) => *v,
                None => true, // §5-ok — hors bornes jamais atteint en pratique, valeur neutre sûre
            };
            let c = match caracteres.get(idx) {
                Some(c) => *c,
                None => ' ', // §5-ok — hors bornes jamais atteint en pratique, caractère neutre sûr
            };
            paire.push((c, reel));
        }
        lignes_masquees.push(paire);
        curseur += longueur + 1; // +1 pour le '\n' consomme par lines()
    }

    let sinks_langage: Vec<&MotifSink> = SINKS.iter().filter(|s| s.langage == langage).collect();

    let mut resultats = Vec::new();
    for (idx_source, ligne_source_brute) in lignes_brutes.iter().enumerate() {
        let contient_source_motif = MOTIFS_SOURCE_LLM.iter().any(|m| {
            idx_source < lignes_masquees.len() && ligne_contient_hors_chaine(&lignes_masquees[idx_source], m)
        });
        if !contient_source_motif {
            continue;
        }
        let identifiant = match extraire_identifiant_affectation(ligne_source_brute) {
            Some(id) => id,
            None => continue,
        };

        let fin_fenetre = (idx_source + FENETRE_LIGNES_MAX).min(lignes_brutes.len());
        let mut mitige = false;
        let mut sink_trouve: Option<(usize, &str)> = None;

        for idx in (idx_source + 1)..fin_fenetre {
            let ligne = lignes_brutes[idx];
            if !contient_identifiant_mot_entier(ligne, &identifiant) {
                continue;
            }
            if MOTIFS_VALIDATION.iter().any(|m| ligne.to_lowercase().contains(m)) {
                mitige = true;
            }
            if sink_trouve.is_none() {
                for sink in &sinks_langage {
                    if idx < lignes_masquees.len() && ligne_contient_hors_chaine(&lignes_masquees[idx], sink.motif) {
                        sink_trouve = Some((idx, sink.categorie));
                        break;
                    }
                }
            }
        }

        if mitige {
            continue;
        }
        if let Some((idx_sink, categorie)) = sink_trouve {
            let ligne_sink_texte = lignes_brutes[idx_sink];
            if ligne_sink_texte.contains(MARQUEUR_EXEMPTION_LIGNE) {
                continue;
            }
            resultats.push(Trouvaille {
                fichier: fichier.to_string(),
                ligne_source: idx_source + 1,
                ligne_sink: idx_sink + 1,
                identifiant: identifiant.clone(),
                categorie: categorie.to_string(),
                extrait_source: ligne_source_brute.trim().to_string(),
                extrait_sink: ligne_sink_texte.trim().to_string(),
            });
        }
    }
    resultats
}

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

fn scanner_fichier(chemin: &Path) -> Result<Vec<Trouvaille>, Erreur> {
    let langage = match langage_de_extension(chemin) {
        Some(l) => l,
        None => return Ok(Vec::new()),
    };
    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, langage))
}

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 langage_de_extension(&chemin).is_some() {
            resultats.push(chemin);
        }
    }
}

fn generer_rapport(trouvailles: &[Trouvaille], nb_fichiers: usize) -> String {
    let mut rapport = String::new();
    rapport.push_str("# Rapport detecteur-sinks-llm-dangereux\n\n");
    rapport.push_str(&format!(
        "{nb_fichiers} fichier(s) Rust/Python examine(s) — {} signalement(s) au total.\n\n",
        trouvailles.len()
    ));
    if trouvailles.is_empty() {
        rapport.push_str("Aucune sortie de LLM non validee atteignant un point dangereux detectee.\n");
        return rapport;
    }
    for t in trouvailles {
        rapport.push_str(&format!(
            "- **{}** sur `{}` — {}:{} (source) -> {}:{} ({})\n  - Source : `{}`\n  - Sink : `{}`\n",
            t.categorie, t.identifiant, t.fichier, t.ligne_source,
            t.fichier, t.ligne_sink, t.categorie, t.extrait_source, t.extrait_sink
        ));
    }
    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-sinks-llm-dangereux <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::*;

    // ── Extraction identifiant d'affectation ─────────────────────────

    #[test]
    fn t_extrait_identifiant_rust_let() {
        assert_eq!(extraire_identifiant_affectation("let reponse_llm = modele.generate(prompt);"), Some("reponse_llm".to_string()));
    }

    #[test]
    fn t_extrait_identifiant_python() {
        assert_eq!(extraire_identifiant_affectation("reponse_llm = modele.generate(prompt)"), Some("reponse_llm".to_string()));
    }

    #[test]
    fn t_ignore_comparaison_egalite() {
        assert_eq!(extraire_identifiant_affectation("if reponse_llm == attendu {"), None);
    }

    #[test]
    fn t_ignore_ligne_sans_egal() {
        assert_eq!(extraire_identifiant_affectation("println!(\"test\");"), None);
    }

    // ── Masquage Rust ─────────────────────────────────────────────────

    #[test]
    fn t_masque_rust_ignore_commentaire() {
        let caracteres: Vec<char> = "// Command::new(reponse_llm)".chars().collect();
        let masque = construire_masque_rust(&caracteres);
        assert!(masque.iter().all(|m| !m));
    }

    #[test]
    fn t_masque_rust_ignore_chaine() {
        let caracteres: Vec<char> = "\"exemple: Command::new(x)\"".chars().collect();
        let masque = construire_masque_rust(&caracteres);
        assert!(masque.iter().all(|m| !m));
    }

    #[test]
    fn t_masque_rust_code_reel_non_masque() {
        let caracteres: Vec<char> = "Command::new(x)".chars().collect();
        let masque = construire_masque_rust(&caracteres);
        assert!(masque.iter().all(|m| *m));
    }

    // ── Masquage Python ───────────────────────────────────────────────

    #[test]
    fn t_masque_python_ignore_commentaire() {
        let caracteres: Vec<char> = "# os.system(reponse_llm)".chars().collect();
        let masque = construire_masque_python(&caracteres);
        assert!(masque.iter().all(|m| !m));
    }

    #[test]
    fn t_masque_python_ignore_docstring_triple() {
        let caracteres: Vec<char> = "\"\"\"exemple os.system(x)\"\"\"".chars().collect();
        let masque = construire_masque_python(&caracteres);
        assert!(masque.iter().all(|m| !m));
    }

    // ── Detection bout en bout ────────────────────────────────────────

    #[test]
    fn t_detecte_sink_rust_commande_shell() {
        let source = "let reponse_llm = modele.generate(prompt);\nCommand::new(reponse_llm).spawn();\n";
        let resultats = analyser_contenu("test.rs", source, Langage::Rust);
        assert_eq!(resultats.len(), 1);
        assert_eq!(resultats[0].categorie, "Commande shell");
    }

    #[test]
    fn t_detecte_sink_python_eval() {
        let source = "reponse_llm = modele.generate(prompt)\neval(reponse_llm)\n";
        let resultats = analyser_contenu("test.py", source, Langage::Python);
        assert_eq!(resultats.len(), 1);
        assert_eq!(resultats[0].categorie, "Evaluation dynamique");
    }

    #[test]
    fn t_ignore_si_validation_presente() {
        let source = "reponse_llm = modele.generate(prompt)\nreponse_llm = sanitize(reponse_llm)\nos.system(reponse_llm)\n";
        let resultats = analyser_contenu("test.py", source, Langage::Python);
        assert!(resultats.is_empty(), "validation presente entre source et sink, aucun signalement attendu");
    }

    #[test]
    fn t_ignore_identifiant_different() {
        let source = "reponse_llm = modele.generate(prompt)\nos.system(autre_commande)\n";
        let resultats = analyser_contenu("test.py", source, Langage::Python);
        assert!(resultats.is_empty());
    }

    #[test]
    fn t_ignore_sink_hors_fenetre() {
        let mut source = String::from("reponse_llm = modele.generate(prompt)\n");
        for _ in 0..30 {
            source.push_str("x = 1\n");
        }
        source.push_str("os.system(reponse_llm)\n");
        let resultats = analyser_contenu("test.py", &source, Langage::Python);
        assert!(resultats.is_empty(), "sink hors de la fenetre de recherche, aucun signalement attendu");
    }

    #[test]
    fn t_ignore_sans_source_llm() {
        let source = "commande = \"ls\"\nos.system(commande)\n";
        let resultats = analyser_contenu("test.py", source, Langage::Python);
        assert!(resultats.is_empty(), "commande ne provient pas d'une source LLM identifiee");
    }

    #[test]
    fn t_ignore_marqueur_exemption() {
        let source = "reponse_llm = modele.generate(prompt)\nos.system(reponse_llm)  # sink-ok\n";
        let resultats = analyser_contenu("test.py", source, Langage::Python);
        assert!(resultats.is_empty());
    }

    #[test]
    fn t_identifiant_prefixe_dune_autre_variable_non_confondu() {
        // "reponse" est une sous-chaine de "reponse2" — une validation sur
        // reponse2 ne doit JAMAIS neutraliser un signalement sur reponse.
        // Regression du bug reel trouve par test manuel (2026-08-19).
        let source = "reponse = modele.generate(prompt)\nos.system(reponse)\n\npropre = sanitize(reponse2)\n";
        let resultats = analyser_contenu("test.py", source, Langage::Python);
        assert_eq!(resultats.len(), 1, "le signalement sur 'reponse' doit survivre malgre 'reponse2' plus loin");
        assert_eq!(resultats[0].identifiant, "reponse");
    }

    #[test]
    fn t_contient_identifiant_mot_entier_rejette_prefixe() {
        assert!(!contient_identifiant_mot_entier("propre = sanitize(reponse2)", "reponse"));
        assert!(contient_identifiant_mot_entier("os.system(reponse)", "reponse"));
        assert!(contient_identifiant_mot_entier("os.system(reponse) ", "reponse"));
    }

    #[test]
    fn t_langage_de_extension_reconnait_rs_et_py() {
        assert_eq!(langage_de_extension(Path::new("f.rs")), Some(Langage::Rust));
        assert_eq!(langage_de_extension(Path::new("f.py")), Some(Langage::Python));
        assert_eq!(langage_de_extension(Path::new("f.txt")), None);
    }

    #[test]
    fn t_analyser_contenu_vide() {
        assert!(analyser_contenu("test.py", "", Langage::Python).is_empty());
    }

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

    #[test]
    fn t_generer_rapport_avec_trouvaille() {
        let trouvailles = vec![Trouvaille {
            fichier: "a.py".to_string(),
            ligne_source: 1,
            ligne_sink: 2,
            identifiant: "reponse_llm".to_string(),
            categorie: "Commande shell".to_string(),
            extrait_source: "reponse_llm = modele.generate(prompt)".to_string(),
            extrait_sink: "os.system(reponse_llm)".to_string(),
        }];
        let rapport = generer_rapport(&trouvailles, 1);
        assert!(rapport.contains("a.py:1"));
        assert!(rapport.contains("a.py:2"));
        assert!(rapport.contains("Commande shell"));
    }
}
29.4 Ko BLAKE3 : 8326bdf5…58f4ed51