Les tests basés sur les propriétés ont détecté une faille de sécurité que je n'aurais jamais trouvée
Krishiv Dakwala
Software Development Engineer
Les vulnérabilités de sécurité se cachent souvent dans les recoins de notre code que nous ne pensons jamais à tester. Nous écrivons des tests unitaires pour le chemin heureux, peut-être quelques cas limites que nous pouvons imaginer, mais qu'en est-il des entrées que nous ne considérerions jamais? Bien souvent, nous supposons que les LLM gèrent ces scénarios par défaut, mais le code généré par un LLM peut être aussi susceptible de contenir des bogues ou des vulnérabilités que le code écrit par un humain. Que se passe-t-il lorsqu'un utilisateur saisit une chaîne malveillante dans votre application?
C'est exactement ce qui s'est produit lorsque nous avons testé la création d'un service de stockage pour une application de clavardage en utilisant l'IA avec les dernières fonctionnalités GA de Kiro. En suivant un flux de travail de développement piloté par les spécifications (SDD), Kiro a soigneusement défini les exigences, extrait des propriétés testables, et implémenté ce qui semblait être un code simple pour stocker et récupérer des clés d'API. L'implémentation semblait solide. Une révision de code l'aurait probablement approuvée. Les tests unitaires traditionnels auraient réussi.
Mais lors de la 75e itération d'un test basé sur les propriétés, quelque chose d'inattendu s'est produit : la propriété d'aller-retour a échoué au complet. Ce qui aurait dû être une simple opération de sauvegarde et de récupération a plutôt exposé une mauvaise gestion des prototypes javascript—un bogue qui peut mener à des problèmes de sécurité à l'avenir si vous ne faites pas attention à éliminer la faille dès le départ.
Cet article raconte l'histoire de la façon dont les tests basés sur les propriétés (PBT) ont détecté une faille de sécurité que l'intuition humaine et les méthodes de test traditionnelles auraient probablement manquée. Nous allons parcourir :
- La spécification et la propriété que Kiro a définies
- L'implémentation apparemment innocente qui contenait une faille critique
- Comment l'exploration systématique de l'espace des entrées par les PBT a révélé la vulnérabilité
- Le correctif qui résout la vulnérabilité
- Pourquoi cela importe pour la création de logiciels sécuritaires
Ceci n'est pas qu'un exercice théorique—c'est un exemple réel de la façon dont les techniques de test automatisées peuvent trouver les cas limites qui empêchent les chercheurs en sécurité de dormir la nuit, avant qu'ils ne se retrouvent en production.
En travaillant sur la création d'une application avec des clients et en parcourant leurs requêtes pour un Spec, Kiro implémentait un système de stockage pour une application de clavardage qui enregistre les données de l'utilisateur dans le localStorage du navigateur. Une fonctionnalité clé était le stockage des clés d'API pour différents fournisseurs de LLM (comme OpenAI, Anthropic, etc.). Les utilisateurs pouvaient enregistrer leurs clés d'API en utilisant un nom de fournisseur comme clé. Cet objet aurait une API comme la suivante :
Kiro, suivant le SDD, a formulé l'exigence suivante :
Examinons plus en détail le critère d'acceptation 2, que Kiro a choisi comme propriété de correction clé :
Kiro appelle ceci une propriété d'« aller-retour ». Les allers-retours sont une forme courante de propriétés de correction, où l'on commence avec une valeur arbitraire, effectue une séquence d'opérations, et se retrouve avec la même valeur. Dans ce cas, nous affirmons que si l'on commence avec des valeurs de chaîne arbitraires provider et key :
- Stocker la
keysousproviderdans le stockage - Récupérer la valeur associée à
provider
Alors la valeur que nous récupérons devrait être égale à key. Si ce n'est pas le cas (par exemple, nous récupérons une valeur différente, ou une exception est levée), alors clairement quelque chose ne va pas avec notre implémentation. Cette spec semble excellente, alors nous l'approuvons, et laissons Kiro implémenter notre API.
Le LLM a produit le code suivant dans le cadre de notre API :
Kiro a ensuite procédé à tester ce code en utilisant les tests basés sur les propriétés, afin de rassembler des preuves que la propriété que nous attendons soit respectée le soit réellement. Pour vérifier la Property 2, Kiro a écrit le test suivant, en utilisant la bibliothèque fast-check pour TypeScript :
Kiro exécute ce test, et à l'essai n° 75 → nous obtenons un échec! Kiro procède à réduire l'échec puis nous rapporte le contre-exemple suivant : le provider "__proto__" et la clé API " ".

Le test basé sur les propriétés a généré des chaînes aléatoires pour les noms de fournisseurs, et après 75 exécutions de test, il a généré la chaîne "__proto__" comme nom de fournisseur. Cela a fait échouer le test avec ce contre-exemple :
Lorsque nous essayons d'enregistrer une clé d'API avec le nom de fournisseur __proto__, puis de la récupérer, quelque chose d'étrange se produit et nous n'obtenons pas la valeur attendue. Kiro nous aide à localiser le problème en utilisant la réduction, pour retirer les détails superflus du problème. Dans ce cas, il réduit notre chaîne apiKey à la plus petite chaîne permise par nos générateurs, ne contenant que des espaces. Cela nous indique que le problème n'est probablement pas lié à la valeur, mais plutôt que la clé étrange est ce qui cause le problème. Si vous connaissez JavaScript, cette erreur vous saute probablement déjà aux yeux, mais si ce n'est pas le cas, poursuivez votre lecture.
Ceci est une caractéristique de la façon dont JavaScript implémente son système d'objets. Les langages de programmation orientée objet plus traditionnels (comme Java, Python et SmallTalk) utilisent l'idée de classes. Chaque classe est un membre statique de la base de code qui décrit comment construire un objet, et décrit la relation d'héritage entre différents objets. JavaScript utilise une approche alternative, appelée « prototypes ». Dans un système d'objets basé sur les prototypes, il n'y a pas de classes. Au lieu de cela, chaque objet contient un champ spécial appelé son prototype qui pointe vers un objet parent duquel il devrait hériter du code et des données. Cela permet de configurer la relation d'héritage de manière dynamique. En JavaScript, ce prototype réside dans le champ __proto__. Lorsque nous avons tenté de définir le champ comme une chaîne, le moteur JavaScript a rejeté cela, et a maintenu le prototype original en place. Cela fait en sorte que nous récupérons le prototype original (un objet vide) lorsque nous recherchons le provider dans la deuxième étape du test de propriété.
Toutes les écritures dans le prototype ne sont pas aussi bénignes que celle-ci. Puisque le provider et l'apiKey sont sous le contrôle de l'attaquant, si l'attaquant trouvait un moyen de faire passer une valeur non-chaîne dans apiKey, il aurait pu injecter des valeurs dans le prototype, ce qui pourrait mener à des lectures ultérieures des propriétés de l'objet retournant potentiellement des valeurs contrôlées par l'attaquant.

Est-ce exploitable? Non. L'objet apiKeys ne vit pas assez longtemps, il est immédiatement libéré après sa sérialisation, et JSON.stringify sait ignorer le champ __proto__. Nous ne faisons également que réécrire le prototype de apiKeys, sans altérer un prototype global. Cependant, des refactorisations du code pourraient introduire de nouveaux chemins de code qui transformeraient cette vulnérabilité non exploitable en une vulnérabilité à impact plus large. La puissance de test offerte par les tests basés sur les propriétés détecte ceci maintenant, aidant à prévenir les incohérences subtiles et les cas limites délicats de se propager dans votre base de code.
Lorsque nous avons tenté d'enregistrer une clé d'API avec le nom de fournisseur __proto__, puis de la récupérer, nous avons obtenu un objet vide {} au lieu de la clé d'API que nous avions enregistrée. Pourquoi cela s'est-il produit? Comprenons un peu mieux le contexte de ce qui s'est passé sous le capot.
Un des avantages des PBT dont nous parlons souvent est le biais. Avec les tests unitaires, quiconque a écrit les tests (modèle ou humain) a tenté de tenir compte des cas limites, mais il est limité par ses propres biais internes. Puisque la même entité (modèle/personne) a écrit l'implémentation, il est logique qu'elle ait du mal à trouver des cas limites qu'elle n'avait pas envisagés lors de l'implémentation. Dans ce cas, l'utilisation de tests basés sur les propriétés nous permet d'accéder à la sagesse collective de ceux qui ont contribué au cadre de test. Dans ce cas, nous injectons dans le processus la connaissance institutionnelle des types de bogues courants (__proto__ est une des chaînes de bogue courantes encodées dans le générateur PBT par les auteurs communautaires de fast-check).
Avant de poursuivre, il convient de noter que le code PBT avait { numRuns: 100 }, ce qui signifie qu'il y a eu 100 itérations du générateur pour tenter de trouver un bogue. Kiro utilise cette valeur par défaut, mais vous pouvez l'augmenter ou la diminuer selon le niveau de confiance recherché pour votre programme. Parfois, vous en voulez plus, mais il peut aussi arriver qu'une implémentation prenne un certain temps à tester et que, par conséquent, la performance d'exécuter 100 tests d'entrée ou plus ne soit pas encore utile à cette étape de votre cycle de développement. La bonne nouvelle est que vous pouvez toujours augmenter ou diminuer cette valeur selon les besoins.
Kiro a implémenté deux mesures défensives basées sur les stratégies d'atténuation hautement efficaces de MITRE :
- Stockage sécurisé (dans
saveApiKey) :
Les objets créés avec Object.create(null) n'ont pas de chaîne de prototypes, donc __proto__ devient simplement une propriété ordinaire.
- Récupération sécurisée (dans loadApiKey) :
Cette histoire illustre pourquoi Kiro utilise les tests basés sur les propriétés dans le cadre du SDD :
- Les propriétés se rattachent directement aux exigences - La propriété « pour tout nom de fournisseur, l'aller-retour devrait fonctionner » est une traduction directe de l'exigence. Lorsque la propriété réussit, nous avons la preuve que l'exigence est satisfaite.
- La génération aléatoire trouve des cas limites inattendus - Les humains et les LLM ont des biais quant aux entrées à tester. La génération aléatoire explore l'espace de manière plus exhaustive.
- Spécifications exécutables - Les propriétés sont des spécifications que vous pouvez exécuter. Elles comblent l'écart entre « ce que le code devrait faire » (les exigences) et « est-ce que le code le fait réellement » (les tests).
- Boucles de rétroaction serrées - Lorsqu'une propriété échoue, vous obtenez un contre-exemple minimal qui facilite le débogage. Kiro peut utiliser cela pour corriger le code, créant un cycle d'itération rapide.
Ce bogue a été découvert lors d'un développement réel avec Kiro. Le test basé sur les propriétés a détecté une faiblesse de sécurité qui aurait été très difficile à trouver par :
- Une révision de code manuelle
- Des tests unitaires traditionnels avec des exemples choisis à la main
- Des tests d'intégration