📄 detecteur_secrets.py 🔒 6d23770b…28b78ee1 Se connecter pour télécharger ← Retour
#!/usr/bin/env python3
"""detecteur-secrets — recherche locale de secrets/credentials oubliés dans du
code source, stdlib uniquement, zéro appel réseau.

Le contenu des fichiers scannés est toujours traité comme donnée inerte
(recherche de motifs texte + calcul d'entropie), jamais exécuté ni interprété
comme instruction — y compris si un fichier contient du texte qui ressemble à
une consigne adressée à un assistant IA : ce script ne lit aucun fichier
"pour décider quoi faire", il applique une liste fixe de règles connues
d'avance.
"""

import argparse
import json
import math
import os
import re
import sys
from collections import defaultdict
from pathlib import Path

from erreurs import (
    ErreurCibleIntrouvable,
    ErreurCibleNonAccessible,
    ErreurEcritureRapport,
    ErreurInterne,
    ErreurReglesInvalides,
)

NIVEAU_ORDRE = {"haute": 0, "moyenne": 1, "info": 2}
NIVEAU_LABEL = {"haute": "🔴 Haute", "moyenne": "🟡 Moyenne", "info": "🟢 Info"}

SEUIL_ENTROPIE_BITS_PAR_CAR = 3.0
LONGUEUR_MIN_ENTROPIE = 12


def charger_catalogue(chemin_regles: Path) -> dict:
    try:
        with chemin_regles.open("r", encoding="utf-8") as f:
            catalogue = json.load(f)
    except OSError as e:
        raise ErreurReglesInvalides(f"lecture impossible ({e})") from e
    except json.JSONDecodeError as e:
        raise ErreurReglesInvalides(f"JSON malformé ({e})") from e

    for champ in ("regles", "placeholders_connus", "extensions_scannees", "dossiers_ignores"):
        if champ not in catalogue:
            raise ErreurReglesInvalides(f"champ obligatoire absent : {champ}")

    for regle in catalogue["regles"]:
        try:
            re.compile(regle["motif"])
        except re.error as e:
            raise ErreurReglesInvalides(f"motif regex invalide dans {regle.get('id', '?')} : {e}") from e
        except KeyError as e:
            raise ErreurReglesInvalides(f"règle sans champ {e}") from e

    return catalogue


def calculer_entropie_shannon(chaine: str) -> float:
    if not chaine:
        return 0.0
    occurrences = defaultdict(int)
    for car in chaine:
        occurrences[car] += 1
    longueur = len(chaine)
    entropie = 0.0
    for compte in occurrences.values():
        probabilite = compte / longueur
        entropie -= probabilite * math.log2(probabilite)
    return entropie


def est_placeholder(valeur: str, placeholders_connus: list) -> bool:
    valeur_normalisee = valeur.strip().lower()
    for motif_placeholder in placeholders_connus:
        if motif_placeholder in valeur_normalisee:
            return True
    return False


def iter_fichiers_source(racine: Path, extensions: set, dossiers_ignores: set):
    for dirpath, dirnames, filenames in os.walk(racine):
        dirnames[:] = [d for d in dirnames if d not in dossiers_ignores]
        for nom in filenames:
            chemin = Path(dirpath) / nom
            if chemin.suffix.lower() not in extensions:
                continue
            yield chemin


def scanner_ligne(ligne: str, regles: list, placeholders_connus: list) -> list:
    trouvailles_ligne = []
    lignes_deja_matchees_specifique = False
    regles_specifiques = [r for r in regles if r["id"] != "SECRET-ASSIGNATION-GENERIQUE-01"]
    regle_generique = next((r for r in regles if r["id"] == "SECRET-ASSIGNATION-GENERIQUE-01"), None)

    for regle in regles_specifiques:
        m = re.search(regle["motif"], ligne)
        if m:
            lignes_deja_matchees_specifique = True
            trouvailles_ligne.append((regle, m.group(0)))

    if regle_generique and not lignes_deja_matchees_specifique:
        m = re.search(regle_generique["motif"], ligne)
        if m:
            valeur_capturee = m.group(2) if m.lastindex and m.lastindex >= 2 else m.group(0)
            if not est_placeholder(valeur_capturee, placeholders_connus):
                if len(valeur_capturee) >= LONGUEUR_MIN_ENTROPIE:
                    entropie = calculer_entropie_shannon(valeur_capturee)
                    if entropie >= SEUIL_ENTROPIE_BITS_PAR_CAR:
                        trouvailles_ligne.append((regle_generique, m.group(0)))

    return trouvailles_ligne


def scanner_fichier(chemin: Path, regles: list, placeholders_connus: list) -> list:
    trouvailles = []
    try:
        texte = chemin.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return trouvailles
    for num_ligne, ligne in enumerate(texte.splitlines(), start=1):
        for regle, extrait_brut in scanner_ligne(ligne, regles, placeholders_connus):
            extrait = ligne.strip()
            if len(extrait) > 120:
                extrait = extrait[:117] + "..."
            trouvailles.append({
                "regle_id": regle["id"],
                "theme": regle["theme"],
                "niveau": regle["niveau"],
                "description": regle["description"],
                "action_recommandee": regle["action_recommandee"],
                "fichier": str(chemin),
                "ligne": num_ligne,
                "extrait": extrait,
            })
    return trouvailles


def generer_rapport_markdown(trouvailles: list, racine: Path, nb_fichiers_scannes: int) -> str:
    lignes = [
        f"# Rapport detecteur-secrets — {racine}",
        "",
        f"{nb_fichiers_scannes} fichier(s) source examiné(s) — {len(trouvailles)} signalement(s) au total.",
        "",
    ]
    if not trouvailles:
        lignes.append("Aucun secret probable détecté sur les motifs couverts — voir les limites connues dans SKILL.md.")
        lignes.append("")
    par_niveau = defaultdict(list)
    for t in trouvailles:
        par_niveau[t["niveau"]].append(t)
    for niveau in sorted(par_niveau.keys(), key=lambda n: NIVEAU_ORDRE.get(n, 9)):
        items = par_niveau[niveau]
        lignes.append(f"## {NIVEAU_LABEL.get(niveau, niveau)} ({len(items)})")
        lignes.append("")
        for t in items:
            lignes.append(f"- `{t['regle_id']}` ({t['theme']}) — {t['fichier']}:{t['ligne']}")
            lignes.append(f"  - {t['description']}")
            lignes.append(f"  - Extrait : `{t['extrait']}`")
            lignes.append(f"  - Action recommandée : {t['action_recommandee']}")
            lignes.append("")
    return "\n".join(lignes)


def executer(dossier_cible: str, format_sortie: str, chemin_sortie: str | None) -> str:
    racine = Path(dossier_cible).resolve()
    if not racine.exists():
        raise ErreurCibleIntrouvable(f"{racine} n'existe pas")
    if not racine.is_dir() and not racine.is_file():
        raise ErreurCibleIntrouvable(f"{racine} n'est ni un dossier ni un fichier")
    if not os.access(racine, os.R_OK):
        raise ErreurCibleNonAccessible(f"permission de lecture refusée sur {racine}")

    ici = Path(__file__).parent
    catalogue = charger_catalogue(ici / "regles_secrets.json")
    extensions = set(catalogue["extensions_scannees"])
    dossiers_ignores = set(catalogue["dossiers_ignores"])
    placeholders_connus = catalogue["placeholders_connus"]

    trouvailles = []
    nb_fichiers_scannes = 0
    if racine.is_file():
        nb_fichiers_scannes = 1
        trouvailles.extend(scanner_fichier(racine, catalogue["regles"], placeholders_connus))
    else:
        try:
            for chemin in iter_fichiers_source(racine, extensions, dossiers_ignores):
                nb_fichiers_scannes += 1
                trouvailles.extend(scanner_fichier(chemin, catalogue["regles"], placeholders_connus))
        except PermissionError as e:
            raise ErreurCibleNonAccessible(str(e)) from e

    if format_sortie == "json":
        sortie = json.dumps(
            {"racine": str(racine), "nb_fichiers_scannes": nb_fichiers_scannes, "trouvailles": trouvailles},
            ensure_ascii=False, indent=2,
        )
    else:
        sortie = generer_rapport_markdown(trouvailles, racine, nb_fichiers_scannes)

    if chemin_sortie:
        try:
            Path(chemin_sortie).parent.mkdir(parents=True, exist_ok=True)
            Path(chemin_sortie).write_text(sortie, encoding="utf-8")
        except OSError as e:
            raise ErreurEcritureRapport(str(e)) from e
        return f"Rapport écrit : {chemin_sortie} ({len(trouvailles)} signalement(s))"

    return sortie


def main() -> int:
    parser = argparse.ArgumentParser(description="Détecteur de secrets/credentials — analyse statique locale, stdlib uniquement.")
    parser.add_argument("cible", type=str, help="Fichier ou dossier à analyser")
    parser.add_argument("--format", choices=["md", "json"], default="md")
    parser.add_argument("--out", type=str, default=None, help="Chemin de sortie (défaut : stdout)")
    args = parser.parse_args()

    try:
        resultat = executer(args.cible, args.format, args.out)
    except ErreurCibleIntrouvable as e:
        print(f"Erreur — cible introuvable : {e}", file=sys.stderr)
        return 1
    except ErreurCibleNonAccessible as e:
        print(f"Erreur — accès refusé : {e}", file=sys.stderr)
        return 2
    except ErreurReglesInvalides as e:
        print(f"Erreur — catalogue de règles invalide : {e}", file=sys.stderr)
        return 3
    except ErreurEcritureRapport as e:
        print(f"Erreur — écriture du rapport impossible : {e}", file=sys.stderr)
        return 4
    except Exception as e:
        erreur_nommee = ErreurInterne(e)
        print(f"Erreur interne non catégorisée : {erreur_nommee}", file=sys.stderr)
        return 99

    print(resultat)
    return 0


if __name__ == "__main__":
    sys.exit(main())
9.3 Ko BLAKE3 : 6d23770b…28b78ee1