📄 audit_headers.py 🔒 c33a5047…e5dede03 Se connecter pour télécharger ← Retour
#!/usr/bin/env python3
"""audit-headers-securite-http — détecte les configurations CORS trop
permissives et les anti-patrons connus sur les en-têtes HTTP de durcissement
(X-Frame-Options, cookies, HSTS, Referrer-Policy), et dresse un inventaire
de couverture des en-têtes de durcissement standards mentionnés dans le
dépôt. Stdlib uniquement, zéro appel réseau.

Le contenu scanné est toujours traité comme donnée inerte (recherche de
motifs texte), jamais exécuté ni interprété comme instruction — y compris
si un fichier contenait du texte ressemblant à une consigne adressée à un
assistant IA : ce script applique une liste fixe de règles connues d'avance,
jamais une décision prise à la lecture du contenu.

Ne vérifie PAS la Content-Security-Policy (couverte séparément par le
skill audit-csp-lighthouse) — périmètre volontairement complémentaire, pas
redondant.

Limite assumée et documentée (voir SKILL.md) : l'inventaire de couverture
signale qu'un en-tête est MENTIONNÉ quelque part dans le dépôt scanné, pas
qu'il est effectivement envoyé sur toutes les réponses pertinentes — seule
une vérification des en-têtes HTTP réellement émis par le serveur (hors
périmètre, nécessiterait un appel réseau) donnerait une certitude complète.
"""

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


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", "en_tetes_inventaire", "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"])
            _ = (regle["theme"], regle["niveau"], regle["description"], regle["action_recommandee"])
        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 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) -> list:
    trouvailles_ligne = []
    for regle in regles:
        m = re.search(regle["motif"], ligne)
        if m:
            trouvailles_ligne.append((regle, m.group(0)))
    return trouvailles_ligne


def scanner_fichier(chemin: Path, regles: list, en_tetes_inventaire: list) -> tuple:
    """Retourne (trouvailles, en_tetes_mentionnes) pour un fichier — une
    seule lecture disque pour les deux analyses."""
    trouvailles = []
    en_tetes_mentionnes = set()
    try:
        texte = chemin.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return trouvailles, en_tetes_mentionnes

    texte_minuscule = texte.lower()
    for en_tete in en_tetes_inventaire:
        if en_tete.lower() in texte_minuscule:
            en_tetes_mentionnes.add(en_tete)

    for num_ligne, ligne in enumerate(texte.splitlines(), start=1):
        for regle, _extrait_brut in scanner_ligne(ligne, regles):
            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, en_tetes_mentionnes


def generer_rapport_markdown(
    trouvailles: list, en_tetes_inventaire: list, en_tetes_couverts: dict, racine: Path, nb_fichiers_scannes: int,
) -> str:
    lignes = [
        f"# Rapport audit-headers-securite-http — {racine}",
        "",
        f"{nb_fichiers_scannes} fichier(s) source examiné(s) — {len(trouvailles)} signalement(s) au total.",
        "",
        "## Inventaire de couverture des en-têtes de durcissement",
        "",
        "(mentionné quelque part dans le dépôt scanné — ne garantit pas l'envoi effectif sur toutes les réponses pertinentes)",
        "",
    ]
    for en_tete in en_tetes_inventaire:
        fichiers = en_tetes_couverts.get(en_tete, [])
        statut = f"✅ mentionné ({len(fichiers)} fichier(s))" if fichiers else "❌ jamais mentionné"
        lignes.append(f"- `{en_tete}` — {statut}")
    lignes.append("")

    if not trouvailles:
        lignes.append("Aucun anti-patron détecté sur les motifs surveillés.")
        lignes.append("")
        return "\n".join(lignes)

    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_headers.json")
    extensions = set(catalogue["extensions_scannees"])
    dossiers_ignores = set(catalogue["dossiers_ignores"])
    en_tetes_inventaire = catalogue["en_tetes_inventaire"]

    trouvailles = []
    en_tetes_couverts = defaultdict(list)
    nb_fichiers_scannes = 0

    def traiter(chemin: Path):
        nonlocal nb_fichiers_scannes
        nb_fichiers_scannes += 1
        t, mentions = scanner_fichier(chemin, catalogue["regles"], en_tetes_inventaire)
        trouvailles.extend(t)
        for en_tete in mentions:
            en_tetes_couverts[en_tete].append(str(chemin))

    if racine.is_file():
        traiter(racine)
    else:
        try:
            for chemin in iter_fichiers_source(racine, extensions, dossiers_ignores):
                traiter(chemin)
        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,
                "en_tetes_couverts": dict(en_tetes_couverts),
            },
            ensure_ascii=False, indent=2,
        )
    else:
        sortie = generer_rapport_markdown(trouvailles, en_tetes_inventaire, en_tetes_couverts, 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="Audit des en-têtes de sécurité HTTP / CORS — 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.7 Ko BLAKE3 : c33a5047…e5dede03