📄 scan_souverainete.py 🔒 425c4cc0…84b0bcdf Se connecter pour télécharger ← Retour
#!/usr/bin/env python3
"""audit-souverainete — analyse statique locale, stdlib uniquement, zéro appel réseau.

Le contenu des fichiers scannés est toujours traité comme donnée inerte
(recherche de motifs texte), jamais exécuté ni interprété comme instruction.
"""

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

DOSSIERS_IGNORES = {
    "target", "node_modules", ".git", "__pycache__", "dist", "build",
    "venv", ".venv", ".mypy_cache", ".pytest_cache", "vendor",
}

EXTENSIONS_SCANNEES = {
    ".py", ".js", ".ts", ".tsx", ".jsx", ".rs", ".go", ".rb", ".php",
    ".java", ".toml", ".json", ".yaml", ".yml", ".env",
}
# .txt volontairement exclu : quasi toujours des logs/rapports/docs, jamais
# du code source réel dans cet écosystème — inclus une fois, ça a fait
# scanner des dizaines de journaux de scan de ~33 000 lignes chacun pour 0
# valeur (dossier scripts/Rapport_*), cf. rapport de validation du skill.

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


def charger_regles(chemin_regles: Path) -> dict:
    with chemin_regles.open("r", encoding="utf-8") as f:
        return json.load(f)


def iter_fichiers_source(racine: Path):
    # os.walk + élagage in-place de dirnames : ne descend JAMAIS dans les
    # dossiers ignorés (contrairement à rglob("*") qui parcourt tout puis
    # filtre après coup — bien trop lent sur un target/ Rust de plusieurs Go).
    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_SCANNEES:
                continue
            yield chemin


def scanner_fichier(chemin: Path, regles_compilees: list) -> list:
    trouvailles = []
    try:
        texte = chemin.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return trouvailles
    lignes = texte.splitlines()
    for regle, motif_compile in regles_compilees:
        for num_ligne, ligne in enumerate(lignes, start=1):
            m = motif_compile.search(ligne)
            if m:
                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"],
                    "alternative_souveraine": regle["alternative_souveraine"],
                    "fichier": str(chemin),
                    "ligne": num_ligne,
                    "extrait": extrait,
                })
    return trouvailles


def iter_manifestes(racine: Path, manifestes_reconnus: list):
    manifestes_reconnus = set(manifestes_reconnus)
    for dirpath, dirnames, filenames in os.walk(racine):
        dirnames[:] = [d for d in dirnames if d not in DOSSIERS_IGNORES]
        for nom in filenames:
            if nom in manifestes_reconnus:
                yield Path(dirpath) / nom


def verifier_crypto_sans_pqc(racine: Path, marqueurs: dict, manifestes_reconnus: list) -> dict | None:
    classique_trouve = []
    pqc_trouve = []
    for chemin in iter_manifestes(racine, manifestes_reconnus):
        try:
            contenu = chemin.read_text(encoding="utf-8", errors="ignore").lower()
        except OSError:
            continue
        for motif in marqueurs["classique"]:
            if motif in contenu:
                classique_trouve.append((motif, str(chemin)))
        for motif in marqueurs["pqc"]:
            if motif in contenu:
                pqc_trouve.append((motif, str(chemin)))
    if classique_trouve and not pqc_trouve:
        return {
            "regle_id": "CRYPTO-SANS-PQC-01",
            "theme": "crypto_classique_sans_pqc",
            "niveau": "info",
            "description": (
                "Cryptographie classique détectée dans les manifestes de "
                f"dépendances ({', '.join(sorted({m for m, _ in classique_trouve}))}) "
                "sans qu'aucune bibliothèque post-quantique ne soit référencée."
            ),
            "alternative_souveraine": (
                "Envisager un schéma hybride (ML-KEM/ML-DSA en complément de la "
                "cryptographie classique) — signal faible niveau projet, pas une preuve "
                "d'usage vulnérable précis."
            ),
            "fichier": classique_trouve[0][1],
            "ligne": 0,
            "extrait": f"marqueur détecté : {classique_trouve[0][0]}",
        }
    return None


def generer_rapport_markdown(trouvailles: list, racine: Path, nb_fichiers_scannes: int) -> str:
    lignes = [
        f"# Rapport audit-souverainete — {racine}",
        "",
        f"{nb_fichiers_scannes} fichier(s) source examiné(s) — {len(trouvailles)} signalement(s) au total.",
        "",
    ]
    if not trouvailles:
        lignes.append(
            "Aucune dépendance non-souveraine détectée sur les thèmes couverts "
            "(cloud, IA générative tierce, télémétrie, paiement, messagerie tierce, "
            "crypto classique sans piste PQC) — voir les limites connues dans SKILL.md."
        )
        lignes.append("")
    par_theme = defaultdict(list)
    for t in trouvailles:
        par_theme[t["theme"]].append(t)
    for theme in sorted(par_theme.keys()):
        items = sorted(par_theme[theme], key=lambda t: NIVEAU_ORDRE.get(t["niveau"], 9))
        lignes.append(f"## {theme} ({len(items)})")
        lignes.append("")
        for t in items:
            lignes.append(f"- **{NIVEAU_LABEL.get(t['niveau'], t['niveau'])}** `{t['regle_id']}` — {t['fichier']}:{t['ligne']}")
            lignes.append(f"  - {t['description']}")
            lignes.append(f"  - Extrait : `{t['extrait']}`")
            lignes.append(f"  - Alternative souveraine : {t['alternative_souveraine']}")
            lignes.append("")
    return "\n".join(lignes)


def main() -> int:
    parser = argparse.ArgumentParser(description="Audit de souveraineté numérique — analyse statique locale, stdlib uniquement.")
    parser.add_argument("dossier_cible", type=str, help="Dossier de code source à 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()

    racine = Path(args.dossier_cible).resolve()
    if not racine.is_dir():
        print(f"Erreur : {racine} n'est pas un dossier valide", file=sys.stderr)
        return 1

    ici = Path(__file__).parent
    catalogue = charger_regles(ici / "regles_souverainete.json")
    regles_compilees = [(r, re.compile(r["motif"], re.IGNORECASE)) for r in catalogue["regles"]]

    trouvailles = []
    nb_fichiers_scannes = 0
    for chemin in iter_fichiers_source(racine):
        nb_fichiers_scannes += 1
        trouvailles.extend(scanner_fichier(chemin, regles_compilees))

    signal_crypto = verifier_crypto_sans_pqc(
        racine, catalogue["crypto_classique_marqueurs"], catalogue["manifestes_reconnus"]
    )
    if signal_crypto:
        trouvailles.append(signal_crypto)

    if args.format == "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 args.out:
        Path(args.out).parent.mkdir(parents=True, exist_ok=True)
        Path(args.out).write_text(sortie, encoding="utf-8")
        print(f"Rapport écrit : {args.out} ({len(trouvailles)} signalement(s))")
    else:
        print(sortie)

    return 0


if __name__ == "__main__":
    sys.exit(main())
7.8 Ko BLAKE3 : 425c4cc0…84b0bcdf