#!/usr/bin/env python3
"""audit-pqc-readiness — évalue la posture post-quantique d'un dépôt de code,
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 —
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.
Principe de classification : la présence isolée de RSA/ECDSA/ECDH n'est PAS
en soi une faute (toute l'industrie en dépend encore) — c'est l'ABSENCE de
toute primitive post-quantique en complément, dans le même fichier, qui
constitue le signal à surveiller (transition hybride non amorcée). Un
algorithme cassé (MD5/SHA-1/RC4/3DES) reste en revanche toujours signalé en
haute sévérité, indépendamment du contexte.
"""
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"}
CLASSIFICATION_LABEL = {
"casse": "🔴 Algorithme cryptographique cassé détecté — remplacement prioritaire, indépendant du contexte post-quantique.",
"classique_seul": "🟡 Cryptographie asymétrique classique sans indice de transition post-quantique détecté dans ce fichier.",
"hybride": "🟢 Cryptographie hybride classique + post-quantique détectée — bonne pratique de transition.",
"pqc_natif": "🟢 Cryptographie post-quantique native détectée (aucune primitive classique dans ce fichier).",
"cle_faible_seule": "🟡 Taille de clé symétrique réduite (AES-128) sans autre signal — CNSA 2.0 recommande 256 bits.",
}
CLASSIFICATION_NIVEAU = {
"casse": "haute",
"classique_seul": "moyenne",
"hybride": "info",
"pqc_natif": "info",
"cle_faible_seule": "moyenne",
}
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["categorie"], 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 classifier_readiness(categories_presentes: set) -> tuple:
"""Décide la classification GLOBALE d'un fichier à partir de l'ensemble
des catégories de règles qui y ont matché au moins une fois. Fonction
pure, testable indépendamment du scan de fichiers."""
if "casse" in categories_presentes:
classification = "casse"
elif "classique_asymetrique" in categories_presentes and "pqc" not in categories_presentes:
classification = "classique_seul"
elif "classique_asymetrique" in categories_presentes and "pqc" in categories_presentes:
classification = "hybride"
elif "pqc" in categories_presentes:
classification = "pqc_natif"
elif "cle_symetrique_faible" in categories_presentes:
classification = "cle_faible_seule"
else:
classification = "pqc_natif" # ensemble vide non attendu en usage normal (voir executer)
return classification, CLASSIFICATION_NIVEAU[classification], CLASSIFICATION_LABEL[classification]
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"],
"categorie": regle["categorie"],
"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 regrouper_par_fichier(trouvailles: list) -> list:
par_fichier = defaultdict(list)
for t in trouvailles:
par_fichier[t["fichier"]].append(t)
resultat = []
for fichier, items in par_fichier.items():
categories = {t["categorie"] for t in items}
classification, niveau, libelle = classifier_readiness(categories)
resultat.append({
"fichier": fichier,
"classification": classification,
"niveau": niveau,
"libelle": libelle,
"trouvailles": sorted(items, key=lambda t: t["ligne"]),
})
return resultat
def generer_rapport_markdown(fichiers_classifies: list, racine: Path, nb_fichiers_scannes: int) -> str:
lignes = [
f"# Rapport audit-pqc-readiness — {racine}",
"",
f"{nb_fichiers_scannes} fichier(s) source examiné(s) — "
f"{len(fichiers_classifies)} fichier(s) avec au moins un signal cryptographique.",
"",
]
if not fichiers_classifies:
lignes.append("Aucun motif cryptographique connu détecté sur les fichiers scannés — voir les limites connues dans SKILL.md.")
lignes.append("")
return "\n".join(lignes)
fichiers_tries = sorted(fichiers_classifies, key=lambda f: NIVEAU_ORDRE.get(f["niveau"], 9))
for f in fichiers_tries:
lignes.append(f"## {f['fichier']}")
lignes.append("")
lignes.append(f"**Classification** : {f['libelle']}")
lignes.append("")
for t in f["trouvailles"]:
lignes.append(f"- `{t['regle_id']}` ({NIVEAU_LABEL.get(t['niveau'], t['niveau'])}) — ligne {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_pqc.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
fichiers_classifies = regrouper_par_fichier(trouvailles)
if format_sortie == "json":
sortie = json.dumps(
{"racine": str(racine), "nb_fichiers_scannes": nb_fichiers_scannes, "fichiers": fichiers_classifies},
ensure_ascii=False, indent=2,
)
else:
sortie = generer_rapport_markdown(fichiers_classifies, 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(fichiers_classifies)} fichier(s) signalé(s))"
return sortie
def main() -> int:
parser = argparse.ArgumentParser(description="Audit de posture post-quantique — 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())