📄 detecteur_injection.py 🔒 f0e652c2…6801f50f Se connecter pour télécharger ← Retour
#!/usr/bin/env python3
"""detecteur-injection-prompt — recherche locale de techniques de dissimulation
de texte/instructions dans du contenu (documents, HTML, config), stdlib
uniquement, zéro appel réseau.

Le contenu des fichiers scannés est toujours traité comme donnée inerte
(recherche de motifs + décodage passif pour inspection), 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 et rapporte, il n'agit jamais sur ce qu'il trouve.

Catégories détectées : caractères Unicode invisibles, contrôles
bidirectionnels (« Trojan Source »), homoglyphes (mélange d'alphabets
visuellement proches), texte masqué par CSS, faux marqueurs de rôle de
conversation, directives d'instruction explicites, charges base64 suspectes.
"""

import argparse
import base64
import json
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"}

MOTIF_BASE64_CANDIDAT = re.compile(r"[A-Za-z0-9+/]{40,}={0,2}")
MOTIF_MOT_UNICODE = re.compile(r"[^\W\d_]+", re.UNICODE)
LONGUEUR_MIN_MOT_HOMOGLYPHE = 3


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

    champs_obligatoires = (
        "regles", "extensions_scannees", "dossiers_ignores",
        "caracteres_invisibles", "caracteres_bidi", "confusables",
        "verbes_instruction_suspects",
        "regle_invisible", "regle_bidi", "regle_homoglyphe", "regle_base64",
    )
    for champ in champs_obligatoires:
        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 _ajouter_trouvaille(trouvailles: list, regle_meta: dict, chemin: Path, num_ligne: int, extrait: str) -> None:
    extrait_court = extrait.strip()
    if len(extrait_court) > 120:
        extrait_court = extrait_court[:117] + "..."
    trouvailles.append({
        "regle_id": regle_meta["id"],
        "theme": regle_meta["theme"],
        "niveau": regle_meta["niveau"],
        "description": regle_meta["description"],
        "action_recommandee": regle_meta["action_recommandee"],
        "fichier": str(chemin),
        "ligne": num_ligne,
        "extrait": extrait_court,
    })


def scanner_regles_regex(chemin: Path, texte: str, regles: list, trouvailles: list) -> None:
    compilees = [(r, re.compile(r["motif"])) for r in regles]
    for num_ligne, ligne in enumerate(texte.splitlines(), start=1):
        for regle, motif_compile in compilees:
            m = motif_compile.search(ligne)
            if m:
                _ajouter_trouvaille(trouvailles, regle, chemin, num_ligne, ligne)


def scanner_caracteres_invisibles(chemin: Path, texte: str, catalogue: dict, trouvailles: list) -> None:
    codepoints = {int(cp, 16) for cp in catalogue["caracteres_invisibles"]}
    for num_ligne, ligne in enumerate(texte.splitlines(), start=1):
        for car in ligne:
            if ord(car) in codepoints:
                nom = catalogue["caracteres_invisibles"][f"{ord(car):x}"]
                _ajouter_trouvaille(trouvailles, catalogue["regle_invisible"], chemin, num_ligne, f"U+{ord(car):04X} {nom}")
                break


def scanner_caracteres_bidi(chemin: Path, texte: str, catalogue: dict, trouvailles: list) -> None:
    codepoints = {int(cp, 16) for cp in catalogue["caracteres_bidi"]}
    for num_ligne, ligne in enumerate(texte.splitlines(), start=1):
        for car in ligne:
            if ord(car) in codepoints:
                nom = catalogue["caracteres_bidi"][f"{ord(car):x}"]
                _ajouter_trouvaille(trouvailles, catalogue["regle_bidi"], chemin, num_ligne, f"U+{ord(car):04X} {nom}")
                break


def scanner_homoglyphes(chemin: Path, texte: str, catalogue: dict, trouvailles: list) -> None:
    confusables = catalogue["confusables"]
    for num_ligne, ligne in enumerate(texte.splitlines(), start=1):
        for mot in MOTIF_MOT_UNICODE.findall(ligne):
            if len(mot) < LONGUEUR_MIN_MOT_HOMOGLYPHE:
                continue
            a_ascii = any(c.isascii() and c.isalpha() for c in mot)
            a_confusable = any(c in confusables for c in mot)
            if a_ascii and a_confusable:
                _ajouter_trouvaille(trouvailles, catalogue["regle_homoglyphe"], chemin, num_ligne, mot)


def scanner_base64_suspect(chemin: Path, texte: str, catalogue: dict, trouvailles: list) -> None:
    verbes = [v.lower() for v in catalogue["verbes_instruction_suspects"]]
    for num_ligne, ligne in enumerate(texte.splitlines(), start=1):
        for candidat in MOTIF_BASE64_CANDIDAT.findall(ligne):
            try:
                decode = base64.b64decode(candidat, validate=True)
                texte_decode = decode.decode("utf-8")
            except (ValueError, UnicodeDecodeError):
                continue
            texte_decode_bas = texte_decode.lower()
            for verbe in verbes:
                if verbe in texte_decode_bas:
                    extrait = texte_decode.strip()
                    if len(extrait) > 120:
                        extrait = extrait[:117] + "..."
                    _ajouter_trouvaille(trouvailles, catalogue["regle_base64"], chemin, num_ligne, f"décodé : {extrait}")
                    break


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_fichier(chemin: Path, catalogue: dict) -> list:
    trouvailles = []
    try:
        texte = chemin.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return trouvailles
    scanner_regles_regex(chemin, texte, catalogue["regles"], trouvailles)
    scanner_caracteres_invisibles(chemin, texte, catalogue, trouvailles)
    scanner_caracteres_bidi(chemin, texte, catalogue, trouvailles)
    scanner_homoglyphes(chemin, texte, catalogue, trouvailles)
    scanner_base64_suspect(chemin, texte, catalogue, trouvailles)
    return trouvailles


def generer_rapport_markdown(trouvailles: list, racine: Path, nb_fichiers_scannes: int) -> str:
    lignes = [
        f"# Rapport detecteur-injection-prompt — {racine}",
        "",
        f"{nb_fichiers_scannes} fichier(s) examiné(s) — {len(trouvailles)} signalement(s) au total.",
        "",
    ]
    if not trouvailles:
        lignes.append("Aucune technique de dissimulation connue détectée — 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_injection.json")
    extensions = set(catalogue["extensions_scannees"])
    dossiers_ignores = set(catalogue["dossiers_ignores"])

    trouvailles = []
    nb_fichiers_scannes = 0
    if racine.is_file():
        nb_fichiers_scannes = 1
        trouvailles.extend(scanner_fichier(racine, catalogue))
    else:
        try:
            for chemin in iter_fichiers_source(racine, extensions, dossiers_ignores):
                nb_fichiers_scannes += 1
                trouvailles.extend(scanner_fichier(chemin, catalogue))
        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 techniques de dissimulation/injection dans du contenu — 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())
11.4 Ko BLAKE3 : f0e652c2…6801f50f