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