Skip to main content

Sécurité au niveau des types : l’avenir de la génération de code IA sécurisé ?

Écrit par
blog feature snyk code green

4 juin 2026

0 minutes de lecture

Introduction

Alors que le code est écrit (et généré) plus rapidement que jamais, les vulnérabilités de sécurité apparaissent malheureusement elles aussi à un rythme inédit. Demander à votre LLM de ne pas inclure de vulnérabilités de sécurité dans son code ne suffit pas toujours. Il devient évident que la manière dont les logiciels sont développés aujourd’hui, manuellement ou avec assistance, ne permet pas d’écrire du code sécurisé de façon fiable, cohérente et vérifiable.

La popularité croissante de Rust a montré qu’il est possible d’éliminer complètement des catégories entières de vulnérabilités, de manière accessible et ergonomique. Rust a ainsi effectivement éliminé toutes (ou presque toutes) les vulnérabilités liées à la corruption de mémoire à la compilation. Pourquoi devrions-nous nous limiter à cette catégorie de vulnérabilités ?

Dans cet article, je vais expliquer comment écrire du code de telle sorte que les vulnérabilités des applications web deviennent impossibles à compiler (ou à vérifier par le système de types), et comment des bibliothèques sécurisées dès la conception ou des wrappers bien conçus peuvent empêcher complètement l’apparition de catégories entières de vulnérabilités, que le code soit écrit manuellement ou par un LLM. Je présenterai des modèles de code en Python et en Rust qui pourraient servir à éliminer des catégories de vulnérabilités.

Pourquoi les types ?

Utilisés correctement, les systèmes de types peuvent être des outils extrêmement puissants. Ils vous permettent de formaliser les invariants d’un système, c’est-à-dire l’ensemble des règles, propriétés et relations qui s’appliquent à chaque donnée de votre programme. Plutôt que de laisser un commentaire utile ou d’effectuer des assertions à l’exécution (qui ne seront peut-être déclenchées que lorsqu’un premier client tentera une opération importante), vous pouvez garantir que chaque appel à votre fonction add ne transmet que deux valeurs de type int, dans tous les cas et dans l’ensemble du code.

D’après mon expérience, le code que j’écris avec des types extrêmement stricts comporte nettement moins de bogues à l’exécution que celui que j’écris sans eux, car je suis obligé de réfléchir à chaque paramètre, à chaque donnée et à chaque entrée. Lorsque ces éléments sont correctement formalisés, je fais beaucoup moins d’erreurs : mon code ne contient alors plus que des bogues liés à la logique métier, plutôt que des erreurs du genre « oups, j’ai passé une chaîne à une fonction qui additionne des entiers ».

De nombreuses vulnérabilités de sécurité ne sont qu’une catégorie particulière de bogues. Si l’affirmation ci-dessus est juste, un système de types suffisamment flexible devrait permettre de résoudre nombre d’entre elles.

Types de confiance

Cet article s’inspire en partie de l’API web Trusted Types. Celle-ci atténue efficacement la plupart des formes de XSS DOM en veillant à ce que les points d’injection XSS n’acceptent que des valeurs considérées comme sûres, par exemple celles assainies par un outil adapté. Il est possible de renforcer cette API à l’aide de CSP pour exiger l’utilisation de l’API Trusted Types et déclencher une TypeError dans le cas contraire.

Le noyau Linux utilise un mécanisme similaire avec la macro __user, qui garantit que les pointeurs provenant de l’espace utilisateur sont traités correctement.

Dans les sections suivantes, nous examinerons comment généraliser cette technique et l’appliquer à n’importe quelle catégorie de vulnérabilités de sécurité.

Résoudre les IDOR

La référence directe non sécurisée à un objet (IDOR) est une vulnérabilité insidieuse qui résulte de l’absence de vérifications d’authentification et/ou d’autorisation lors d’appels à une API. L’exemple classique est un point de terminaison d’API qui prend en paramètre un identifiant utilisateur incrémentiel et renvoie les données de cet utilisateur. Mais si vous modifiez cet identifiant, le point de terminaison renvoie les données d’un autre utilisateur, sans vérifier correctement les autorisations.

Statistiquement, vous, lecteur, utilisez probablement Python. Nous allons donc commencer par examiner cette vulnérabilité et voir comment la résoudre en Python à l’aide de simples annotations de types.

En Python

Commençons par un exemple vulnérable :

import sqlite3

from fastapi import FastAPI, Depends

app = FastAPI()

DATABASE = "app.db"

def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
    finally:
        conn.close()

@app.get("/balance/{user_id}")
def get_balance(user_id: int, db: sqlite3.Connection = Depends(get_db)):
    cursor = db.execute(f"SELECT balance FROM accounts WHERE user_id = {user_id}")
    row = cursor.fetchone()
    return {"uid": user_id, "balance": row["balance"]}

Cet appel d’API semble tout à fait classique : vous transmettez un identifiant utilisateur sous forme d’entier, interrogez la base de données et renvoyez le résultat dans la réponse. Sauf que nous avons oublié d’ajouter les vérifications d’authentification et d’autorisation ! N’importe quel utilisateur peut demander le solde d’un autre (s’il connaît son identifiant) : c’est une IDOR classique. Nous recevons le signalement de la vulnérabilité, payons la prime au chercheur et modifions la fonction comme suit :

@app.get("/balance/{user_id}")
def get_balance(user_id: int, db: sqlite3.Connection = Depends(get_db)):
    if not (check_authentication() and check_authorization()): return 403
    ...

Parfait, les données utilisateur sont en sécurité. Mais c’est une erreur terriblement facile à commettre. Oublier une seule vérification d’authentification sur un seul point de terminaison d’API peut avoir de lourdes conséquences. Voyons maintenant le même point de terminaison, en utilisant le système de types pour éviter que cette vulnérabilité ne se reproduise :

import sqlite3

from fastapi import FastAPI, Depends
from typing import Never, NewType, Final
from collections.abc import Generator
from contextlib import contextmanager

app = FastAPI()

DATABASE = "app.db"

def get_db():
...

UserID = NewType("UserID", int)

class AuthenticationProvider:
    def do_authn(self) -> "AuthenticationGuard":
        # do something to check the auth
        return AuthenticationGuard(guard=AuthenticationGuard._INSTANTIATION_TOKEN)  # pyright: ignore[reportPrivateUsage, reportArgumentType]

class AuthenticationGuard:
    _INSTANTIATION_TOKEN: Final = object()

    def __init__(self, *, guard: Never):
        # could also do some runtime checks to ensure caller
        assert guard is self._INSTANTIATION_TOKEN

class UncheckedUserID:
    __value: UserID

    def __init__(self, user_id: UserID):
        self.__value = user_id

    @contextmanager
    def ensure_authn(self, guard: AuthenticationGuard) -> Generator[UserID, None, None]:
        assert guard
        yield self.__value


@app.get("/balance/{user_id}")
def get_balance(unauth_user_id: UncheckedUserID = Depends(UncheckedUserID), db: sqlite3.Connection = Depends(get_db)):
    authguard = AuthenticationProvider().do_authn()
    with unauth_user_id.ensure_authn(authguard) as user_id:
        cursor = db.execute(f"SELECT balance FROM accounts WHERE user_id = {user_id}")
        row = cursor.fetchone()
        return {"uid": user_id, "balance": row["balance"]}

Il y a nettement plus de code ici, mais au final, il serait de toute façon intégré à votre code d’authentification et d’autorisation.

Le principal changement dans ce nouveau code est que nous ne faisons jamais circuler de types de base (comme l’identifiant utilisateur de type int dans les exemples précédents). Les données d’entrée sont encapsulées dans une classe opaque, à laquelle vous ne pouvez accéder qu’après avoir prouvé que les étapes d’authentification et d’autorisation ont été effectuées. En faisant en sorte que votre classe UncheckedUserID ne puisse rien faire d’utile (par exemple, être utilisée dans une expression SQL), votre vérificateur de types signalera systématiquement une erreur si vous oubliez d’effectuer la vérification d’authentification nécessaire pour extraire la véritable valeur user_id.

Python étant un langage dynamique, il est techniquement possible de contourner la vérification et d’accéder à la valeur interne sans fournir un AuthenticationGuard valide. Espérons toutefois qu’une revue de code repérera ce code indésirable. D’autres langages, comme Rust, offrent des garanties bien plus fortes : même avec du code peu élégant, il est impossible d’accéder directement à la valeur interne de la classe wrapper. Bien sûr, le code s’exécute toujours, donc vous pourriez prendre des mesures extrêmes, comme vider la mémoire de l’instance. Mais si votre seul objectif est de garantir la cohérence de l’authentification, pourquoi feriez-vous cela ?

En Rust

Les spécificateurs de visibilité de Rust contrôlent rigoureusement quel code peut accéder à la valeur user_id encapsulée, ce qui renforce encore la fiabilité des vérifications d’authentification et d’autorisation. Dans l’exemple ci-dessous, nous utilisons Axum avec un extracteur pour garantir que la valeur d’entrée est correctement désérialisée dans notre classe wrapper sécurisée.

use axum::{response::IntoResponse, routing::get, Json, Router};

mod unchecked_input {
    use axum::extract::{FromRequestParts, Path};
    #[derive(serde::Deserialize, serde::Serialize, Debug)]
    pub struct UserID(u32);

    #[derive(FromRequestParts)]
    pub struct UncheckedUserID {
        #[from_request(via(Path))]
        value: UserID,
    }

    impl UncheckedUserID {
        pub const fn ensure_authn(self, _: super::authentication::AuthenticationGuard) -> UserID {
            self.value
        }
    }
}

mod authentication {
    use std::marker::PhantomData;

    pub struct AuthenticationGuard {
        _internal: PhantomData<()>,
    }

    pub struct AuthenticationProvider {}
    impl AuthenticationProvider {
        pub const fn do_auth() -> Option<AuthenticationGuard> {
            // do something to check the auth
            Some(AuthenticationGuard {
                _internal: PhantomData,
            })
        }
    }
}

async fn get_balance(user_id: unchecked_input::UncheckedUserID) -> impl IntoResponse {
    match authentication::AuthenticationProvider::do_auth() {
        Some(guard) => {
            let user_id = user_id.ensure_authn(guard);
            // go and do some SQL
            Json(serde_json::json!({"uid": user_id, "balance": 0.0}))
        }
        None => Json(serde_json::json!({"error": "auth failure"})),
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/balance/{user_id}", get(get_balance));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

Mise en pratique

Bien sûr, cette méthode de réduction des vulnérabilités de sécurité nécessite une forte adhésion, surtout dans un projet logiciel déjà bien établi. Il faudra mettre en place une infrastructure de développement pour garantir l’application systématique de ces modèles. Deux approches principales sont possibles : utiliser des bibliothèques wrapper ou créer des règles de lint personnalisées.

Prenons l’exemple de Python. En tant qu’organisation, vous pourriez imposer l’utilisation de la bibliothèque MyOrgFastAPI, un wrapper léger pour FastAPI, et veiller à ce que tous les points de terminaison d’API utilisent des types personnalisés qui répondent à ces exigences de sécurité. Aucun point de terminaison ne devrait accepter un argument de type str : il devrait obligatoirement s’agir d’une forme de UncheckedString.

Autre possibilité : si vous ne souhaitez pas maintenir une bibliothèque wrapper personnalisée, vous pouvez implémenter ces règles au niveau du linter. Les développeurs ne seront pas avertis aussi tôt, mais cette approche peut tout de même offrir le même filet de sécurité en interdisant les types de base.

Quelle que soit l’approche choisie, vous vous assurez que chaque cas est correctement géré. Ce modèle de code peut naturellement être étendu à la protection contre toutes sortes de vulnérabilités :

  • Un UncheckedString doit être assaini correctement avant d’être converti en chaîne brute et renvoyé à l’utilisateur, afin d’atténuer les risques de XSS

  • Un UncheckedString ne peut pas être concaténé à une requête SQL, ce qui atténue les vulnérabilités SQL classiques

  • Un UncheckedString ne peut pas être concaténé à une commande, ce qui atténue les vulnérabilités liées à l’injection de commandes.

Et la liste continue.

Ces modèles s’appliquent aussi bien aux développeurs qu’aux agents d’IA. Demander à un agent d’IA de vérifier l’authentification partout n’est peut-être pas totalement fiable, mais imposer cette vérification au moment de la compilation ou du lint peut être extrêmement efficace.

Conclusion

Mettre en œuvre la sécurité à ce niveau, celui des types, peut être extrêmement efficace pour les développeurs comme pour les agents d’IA. Même si cela implique un investissement initial, en particulier pour les projets logiciels existants, les avantages peuvent être considérables : des catégories entières de vulnérabilités peuvent être éliminées.

La manière dont vous gérez vos données dépend de chaque projet, mais y réfléchir dès le départ peut grandement contribuer à écrire du code sécurisé.

LIVRE BLANC

La crise de la sécurité de l’IA dans votre environnement Python

Avec l’accélération fulgurante du développement, savez-vous vraiment à quoi votre environnement d’IA peut accéder ?