// ⛔ 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"));
}
}