📄 audit_csp.py 🔒 fa4635fe…971400e1 Se connecter pour télécharger ← Retour
#!/usr/bin/env python3
"""audit-csp-lighthouse — détecte les motifs HTML/JS incompatibles avec une
Content-Security-Policy stricte et les points de performance/best-practices
Lighthouse détectables statiquement (style/script inline, pseudo-protocole
javascript, contenu mixte http non chiffré). 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.

Inclut les fichiers .rs dans le périmètre : un serveur web écrit en Rust
embarque typiquement son HTML sous forme de chaînes de caractères
(`format!`, chaînes brutes `r#"..."#`) plutôt que dans des fichiers .html
séparés — limiter le scan aux extensions .html/.js/.css laisserait ce cas,
pourtant le plus courant pour ce type d'architecture, hors de portée.
"""

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", "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) -> 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):
            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 audit-csp-lighthouse — {racine}",
        "",
        f"{nb_fichiers_scannes} fichier(s) source examiné(s) — {len(trouvailles)} signalement(s) au total.",
        "",
    ]
    if not trouvailles:
        lignes.append("Aucun motif surveillé détecté — 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_csp.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["regles"]))
    else:
        try:
            for chemin in iter_fichiers_source(racine, extensions, dossiers_ignores):
                nb_fichiers_scannes += 1
                trouvailles.extend(scanner_fichier(chemin, catalogue["regles"]))
        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="Audit CSP/Lighthouse — 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())
8.1 Ko BLAKE3 : fa4635fe…971400e1