security.rs 89 lignes · 4488 octets
//! Contenu de sécurité injecté dans le plan : garde-fous transversaux (statiques)
//! et contrat de sécurité par écran (dérivé du catalogue).

use crate::catalogue::{Channel, Functionality};

/// Identifiant de section pour le bloc statique (transversal + conventions).
pub const GUIDES_ID: &str = "screen-select:security-guides";
/// Identifiant de section pour le contrat de sécurité par écran.
pub const PER_SCREEN_ID: &str = "screen-select:security-per-screen";

/// Corps statique : sécurité transversale (obligatoire, hors écran) + conventions
/// de rendu sûr. Indépendant de la sélection.
pub fn guides_body() -> String {
    let mut s = String::new();
    s.push_str("## Sécurité transversale (obligatoire, hors écran)\n\n");
    s.push_str("- [ ] Middleware de headers sur TOUTES les réponses : `Content-Security-Policy` (viser sans `unsafe-inline`/`unsafe-eval`), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`.\n");
    s.push_str("- [ ] Validation de configuration au démarrage : refuser `ADMIN_PASSWORD=password` ; avertir si `APP_DEBUG=true`, `COOKIE_SECURE=false` ou `RUST_LOG=debug` en profil production ; secrets uniquement via variables d'environnement.\n");
    s.push_str("- [ ] Cookies de session : `HttpOnly`, `Secure` en production, `SameSite`.\n");
    s.push_str("- [ ] `cargo audit` régulier ; confirmer que le JWT est signé en **HMAC** (HS256/384/512), pas RSA (neutralise RUSTSEC-2023-0071) ; `accept_invalid_certs` SMTP/IMAP à `false` en production.\n\n");
    s.push_str("## Conventions de rendu sûr\n\n");
    s.push_str("- **Escaping contextuel** : ne jamais interpoler une valeur contrôlée par l'utilisateur dans un contexte JavaScript (`<script>`). L'échappement HTML d'Askama n'échappe PAS le contexte JS (le backslash notamment). Passer la valeur par un attribut `data-*` et la lire via `dataset`.\n");
    s.push_str("- Données dynamiques d'API côté client : `textContent`, jamais `innerHTML`.\n");
    s.push_str("- URLs : `encodeURIComponent` / helper `urlenc()` du core ; aucune donnée personnelle en query string.\n");
    s.push_str("- Aucun filtre `|safe` Askama sur une valeur contrôlée par l'utilisateur.\n");
    s
}

/// Corps dérivé du catalogue : un paragraphe par écran des fonctionnalités
/// sélectionnées (niveau d'auth, CSRF si browser, pièges spécifiques).
pub fn reference_body(selected: &[&'static Functionality]) -> String {
    let mut s = String::new();
    s.push_str("## Contrat de sécurité par écran\n\n");
    for f in selected {
        for sc in f.screens {
            s.push_str(&format!(
                "### {} (`{}`) — {}\n\n",
                sc.name,
                sc.suggested_route,
                sc.auth_level.label()
            ));
            if matches!(sc.channel, Channel::Browser) {
                s.push_str("- Canal *browser* ⇒ valider le token CSRF sur chaque `POST` (`validate_csrf_token`).\n");
            }
            for note in sc.security_notes {
                s.push_str(&format!("- {note}\n"));
            }
            s.push('\n');
        }
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::catalogue;

    #[test]
    fn guides_couvre_les_axes_transversaux() {
        let g = guides_body();
        assert!(g.contains("Sécurité transversale"));
        assert!(g.contains("Content-Security-Policy"));
        assert!(g.contains("X-Frame-Options"));
        assert!(g.contains("ADMIN_PASSWORD"));
        assert!(g.contains("HMAC"));
        assert!(g.contains("Conventions de rendu sûr"));
        assert!(g.contains("textContent"));
    }

    #[test]
    fn reference_rend_le_contrat_par_ecran() {
        let two_factor = catalogue::by_key("two_factor").unwrap();
        let r = reference_body(&[two_factor]);
        assert!(r.contains("## Contrat de sécurité par écran"));
        assert!(r.contains("### TwoFactorSetup (`/settings/2fa`) — Authentifié"));
        // canal browser ⇒ rappel CSRF automatique
        assert!(r.contains("CSRF"));
        // piège spécifique repris du catalogue
        assert!(r.to_lowercase().contains("rawquery"));
    }

    #[test]
    fn reference_marque_le_niveau_admin() {
        let admin = catalogue::by_key("admin_users").unwrap();
        let r = reference_body(&[admin]);
        assert!(r.contains("— Administrateur"));
        assert!(r.contains("ensure_admin"));
    }
}