Le Pouvoir des Dix

Règles pour développer du code critique sécurisé1

Gerard J. Holzmann

Traducteur

watchinofoye

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Le Pouvoir des Dix

- Auteur : Gerard J. Holzmann

- Traducteur : watchinofoye

- Parution : 28 décembre 2020

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2020 Gerard J. Holzmann . Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Dix règles pour écrire du code sécurisé

- II-1. Règle 1

- II-2. Règle 2

- II-3. Règle 3

- II-4. Règle 4

- II-5. Règle 5

- II-6. Règle 6

- II-7. Règle 7

- II-8. Règle 8

- II-9. Règle 9

- II-10. Règle 10

- III. Remerciements Developpez.com

- IV. Notes

I - Introduction

La plupart des projets sérieux de développement de logiciels utilisent des directives de codage. Ces directives visent à énoncer les règles de base pour la rédaction du logiciel : comment il doit être structuré et quelles fonctionnalités du langage doivent ou ne doivent pas être utilisées. Curieusement, il y a peu de consensus sur ce qu’est une bonne norme de codage. Parmi les nombreux documents qui ont été rédigés, il y a quelques modèles remarquables, si ce n’est que chaque nouveau document tend à être plus long que le précédent. Il en résulte que la plupart des directives existantes contiennent bien plus d’une centaine de règles, parfois avec des justifications douteuses. Certaines règles, en particulier celles qui tentent de stipuler l’utilisation d’espaces dans les programmes, peuvent avoir été introduites par simple préférence personnelle ; d’autres sont destinées à prévenir des types d’erreurs très spécifiques et peu probables, résultant d’efforts de codage antérieurs au sein d’une même organisation. Il n’est pas surprenant que les directives de codage existantes aient tendance à avoir peu d’effet sur ce que les développeurs font réellement lorsqu’ils écrivent du code. L’aspect le plus négatif de nombreuses directives est qu’elles permettent rarement des contrôles de conformité complets basés sur des outils. Ces contrôles sont importants, car il est souvent impossible d’examiner manuellement les centaines de milliers de lignes de code écrites pour des applications plus importantes.

Le bénéfice des directives de codage existantes est donc souvent faible, même pour les applications critiques. Un ensemble vérifiable de règles de codage bien choisies pourrait cependant rendre les composants logiciels critiques plus analysables, pour des propriétés qui vont au-delà du respect de l’ensemble de règles lui-même. Pour être efficace, l’ensemble de règles doit être réduit et suffisamment clair pour être facilement compris et mémorisé. Les règles devront être suffisamment précises pour pouvoir être vérifiées automatiquement. Pour fixer une limite raisonnable au nombre de directives tout en étant efficace, je vais démontrer que nous pouvons tirer un avantage significatif à nous limiter à dix règles au maximum. Bien entendu, un ensemble aussi restreint ne peut pas être exhaustif, mais il peut nous permettre d’obtenir des effets mesurables sur la fiabilité et la vérifiabilité des logiciels. Pour favoriser une vérification fiable, les règles sont quelque peu strictes - on pourrait même dire draconiennes. Le compromis, cependant, doit être clair. Lorsque cela compte vraiment, en particulier dans le développement de codes critiques pour la sécurité, il peut être utile de faire un effort supplémentaire et de vivre avec des limites plus strictes que ce qui est souhaitable. En retour, nous devrions être en mesure de démontrer de manière plus convaincante que les logiciels critiques fonctionneront comme prévu.

II - Dix règles pour écrire du code sécurisé

Le choix du langage pour un code critique sécurisé est en soi une considération clef, mais nous n’en débattrons pas ici. Dans beaucoup d’organisations, JPL (Jet Propulsion Laboratory) incluse, le code critique est écrit en C. Grâce à sa longue histoire, ce langage dispose de nombreux outils de support, notamment de puissants analyseurs de code source, des extracteurs de modèles logiques, des outils métrologiques, des débogueurs, des outils de support de test, ainsi qu’un choix de compilateurs stables et matures. Pour cette raison, le C est également la cible de la majorité des directives de codage qui ont été développées. Pour des raisons assez pragmatiques, nos règles de codage visent donc principalement le C et tentent d’optimiser notre capacité à vérifier de manière plus approfondie la fiabilité des applications critiques écrites en C.

Les règles suivantes peuvent présenter des avantages, surtout si leur faible nombre signifie que les développeurs les respecteront effectivement. Chaque règle est suivie d’une brève justification de son utilisation.

II-1 - Règle 1

Limitez tout le code à des constructions de flux de contrôle très simples - n’utilisez pas les instructions goto, les constructions setjmp ou longjmp, et la récursion directe ou indirecte.

Justification : la simplification du flux de contrôle se traduit par des capacités de vérification plus importantes et se traduit souvent par une amélioration de la clarté du code. Le bannissement de la récursivité est peut-être la plus grande surprise ici. Sans récursion, par contre, nous sommes assurés d’avoir un graphe d’appel de fonction acyclique2, qui peut être exploité par les analyseurs de code et qui peut directement aider à prouver que toutes les exécutions qui devraient être limitées le sont. (Notez que cette règle n’exige pas que toutes les fonctions aient un seul point de retour – bien que cela simplifie souvent aussi le flux de contrôle. Il y a cependant suffisamment de cas où un retour d’erreur précoce est la solution la plus simple).

II-2 - Règle 2

Toutes les boucles doivent avoir une limite supérieure fixe. Il doit être trivialement possible pour un outil de vérification de prouver statiquement qu’une limite supérieure prédéfinie sur le nombre d’itérations d’une boucle ne peut pas être dépassée. Si la limite de la boucle ne peut pas être prouvée statiquement, la règle est considérée comme enfreinte.

Justification : l’absence de récursion et la présence de limites de boucle empêchent le code d’échapper à notre contrôle. Bien entendu, cette règle ne s’applique pas aux itérations qui sont censées ne pas se terminer (par exemple, dans un ordonnanceur de processus). Dans ces cas particuliers, la règle inverse est appliquée : il doit être possible de prouver statiquement que l’itération ne peut pas se terminer.

Une façon de soutenir la règle est d’ajouter une limite supérieure explicite à toutes les boucles qui ont un nombre variable d’itérations (par exemple, un code qui traverse une liste chaînée). Lorsque la limite supérieure est dépassée, un échec d’assertion est déclenché, et la fonction contenant l’itération défaillante renvoie une erreur. (Voir la règle 5 sur l’utilisation des assertions).

II-3 - Règle 3

Ne pas utiliser l’allocation de mémoire dynamique après l’initialisation.

Justification : cette règle est courante pour les logiciels critiques sécurisés et apparaît dans la plupart des directives de codage. La raison est simple : les allocateurs de mémoire, tels que « malloc » et les « ramasse-miettes », ont souvent un comportement imprévisible qui peut avoir un impact significatif sur les performances. Une catégorie notable d’erreurs de codage provient également d’une mauvaise utilisation des routines d’allocation et de libération de mémoire : oublier de libérer de la mémoire ou continuer à utiliser la mémoire après sa libération, tenter d’allouer plus de mémoire que celle physiquement disponible, dépasser les limites de la mémoire allouée, etc. Le fait de forcer toutes les applications à utiliser une zone de mémoire fixe et préallouée peut éliminer bon nombre de ces problèmes et faciliter la vérification de l’utilisation de la mémoire. Notez que la seule façon de réclamer dynamiquement de la mémoire en l’absence d’allocation de mémoire du tas est d’utiliser la mémoire de la pile. En l’absence de récursion (règle 1), une limite supérieure pour l’utilisation de la mémoire de pile peut être établie statiquement, permettant ainsi de prouver qu’une application vivra toujours dans les limites de ses moyens de mémoire préallouée.

II-4 - Règle 4

Aucune fonction ne doit être plus longue que ce qui peut être imprimé sur une seule feuille de papier, dans un format de référence standard, avec une ligne par instruction et une ligne par déclaration. En règle générale, cela signifie qu’il n’y a pas plus de 60 lignes de code par fonction.

Justification : chaque fonction doit être une unité logique dans le code qui est compréhensible et vérifiable en tant qu’unité. Il est beaucoup plus difficile de comprendre une unité logique qui s’étend sur plusieurs écrans d’ordinateur ou sur plusieurs pages lors de l’impression. Des fonctions excessivement longues sont souvent le signe d’un code mal structuré.

II-5 - Règle 5

La densité d’assertions du code doit se situer en moyenne à un minimum de deux assertions par fonction. Les assertions sont utilisées pour vérifier des conditions anormales qui ne devraient jamais se produire dans des exécutions réelles. Les assertions doivent toujours être exemptes d’effets secondaires et doivent être définies comme des tests booléens. Lorsqu’une assertion échoue, une action de récupération doit être entreprise, par exemple en renvoyant une condition d’erreur à l’appelant de la fonction qui exécute l’assertion défaillante. Toute assertion pour laquelle un outil de vérification statique peut prouver qu’elle ne peut jamais échouer ou ne jamais se maintenir enfreint cette règle (c’est-à-dire qu’il n’est pas possible de satisfaire la règle en ajoutant des déclarations assert(true) inutiles).

Justification : les statistiques relatives aux efforts de codage industriel indiquent que les tests unitaires trouvent souvent au moins un défaut pour 10 à 100 lignes de code écrites. Les chances d’intercepter des défauts augmentent avec la densité des assertions. L’utilisation d’assertions est également souvent recommandée dans le cadre d’une forte stratégie de codage défensif. Les assertions peuvent être utilisées pour vérifier les conditions préalables et postérieures des fonctions, les valeurs des paramètres, les valeurs de retour des fonctions et les invariants de boucle.

Comme les assertions sont sans effet secondaire, elles peuvent être désactivées de manière sélective après avoir été testées dans un code à performances critiques.

Une utilisation typique d’une assertion serait la suivante :

if (!c_assert(p >= 0) == true) {
return ERROR;
}

Avec une assertion définie comme ceci :

#define c_assert(e) ((e) ? (true) : \
tst_debugging("%s,%d: assertion ’%s’ failed\n”, \
__FILE__, __LINE__, #e), false)

Dans cette définition, __FILE__ et __LINE__ sont prédéfinies par le préprocesseur pour produire le nom du fichier et le numéro de ligne de l’assertion défaillante. La syntaxe #e transforme la condition d’assertion e en une chaîne de caractères qui est affichée dans le message d’erreur. Dans le code destiné à un processeur embarqué, il n’y a bien sûr pas de place pour afficher le message d’erreur lui-même – dans ce cas, l’appel à tst_debugging est transformé en no-op et l’assertion se transforme en un pur test booléen qui permet de récupérer l’erreur d’un comportement anormal.

II-6 - Règle 6

Les objets de données doivent être déclarés au plus petit niveau de portée possible.3

Justification : cette règle soutient un principe de base de la dissimulation des données. Il est évident que si un objet est hors de portée, sa valeur ne peut pas être référencée ou corrompue. De même, si une valeur erronée d’un objet doit être diagnostiquée, moins il y a de déclarations où la valeur aurait pu être attribuée, plus il est facile de diagnostiquer le problème. La règle décourage la réutilisation de variables à des fins multiples et incompatibles, qui peut compliquer le diagnostic des erreurs.

II-7 - Règle 7

La valeur de retour des fonctions, dans les cas où le type de retour n’est pas void, doit être vérifiée par chaque fonction appelante, et la validité des paramètres doit être vérifiée à l’intérieur de chaque fonction.

Justification : c’est peut-être la règle la plus fréquemment enfreinte et donc, d’une certaine manière, la plus suspecte en règle générale. Dans sa forme la plus stricte, cette règle signifie que même la valeur de retour des appels à printf et close doit être vérifiée. On pourrait cependant argumenter que si la réponse à une erreur n’était pas différente, à juste titre, de la réponse à un succès, il n’est guère utile de vérifier explicitement une valeur de retour.

C’est souvent le cas des appels à printf et close. Dans de tels cas, il peut être acceptable de convertir explicitement la valeur de retour de la fonction à (void) – indiquant ainsi que le programmeur décide explicitement et non accidentellement d’ignorer une valeur de retour.

Dans les cas plus douteux, un commentaire doit être présent pour expliquer pourquoi une valeur de retour n’est pas pertinente. Dans la plupart des cas, la valeur de retour d’une fonction ne doit pas être ignorée, surtout si les valeurs de retour d’erreur doivent être propagées en amont de la chaîne d’appels de la fonction.

Les bibliothèques standard enfreignent notoirement cette règle, avec des conséquences potentiellement graves. Voyez par exemple ce qui se passe si vous exécutez accidentellement strlen(0) ou strcat(s1, s2, -1) avec la bibliothèque de chaînes de caractères C standard – ce n’est pas joli. En conservant cette règle générale, nous nous assurons que les exceptions doivent être justifiées, les vérificateurs automatiques signalant les violations. Souvent, il sera plus facile de se conformer à la règle que d’expliquer pourquoi la non-conformité pourrait être acceptable.

II-8 - Règle 8

L’utilisation du préprocesseur doit être limitée à l’inclusion de fichiers d’en-tête et de simples définitions de macros. Le collage de jetons (##), les listes d’arguments variables (ellipses) et les appels récursifs de macro ne sont pas autorisés. Toutes les macros doivent se développer en unités syntaxiques complètes. L’utilisation de directives de compilation conditionnelles est également souvent douteuse, mais ne peut pas toujours être évitée. Cela signifie qu’il devrait rarement y avoir une justification pour plus d’une ou deux directives de compilation conditionnelle, même dans de grands projets de développement de logiciels, au-delà de l’utilisation standard qui évite l’inclusion multiple du même fichier d’en-tête. Chaque utilisation de ce type devrait être signalée par un outil de vérification et justifiée dans le code.

Justification : le préprocesseur C est un puissant outil d’obfuscation4 qui peut détruire la clarté du code et embrouiller de nombreux vérificateurs basés sur le texte. L’effet des constructions dans un code de préprocesseur non restreint peut être extrêmement difficile à déchiffrer, même avec une définition formelle du langage à disposition. Dans une nouvelle implémentation du préprocesseur C, les développeurs doivent recourir à des mises en œuvre antérieures comme référence pour l’interprétation d’une définition complexe du langage dans la norme C. La justification de la mise en garde contre la compilation conditionnelle est tout aussi importante. Notez qu’avec seulement dix directives de compilation conditionnelle, il pourrait y avoir jusqu’à 2^10 versions possibles du code, dont chacune devrait être testée – ce qui entraînerait une augmentation considérable de l’effort de test requis.

II-9 - Règle 9

L’utilisation des pointeurs devrait être limitée. Plus précisément, il n’est pas permis d’utiliser plus d’un niveau de déréférencement. Les opérations de déréférencement des pointeurs ne peuvent pas être cachées dans les macro-définitions ou dans les déclarations typedef. Les pointeurs de fonction ne sont pas autorisés.

Justification : les pointeurs sont facilement utilisés à mauvais escient, même par des programmeurs expérimentés. Ils peuvent rendre difficile le suivi ou l’analyse du flux de données dans un programme, en particulier par les analyseurs statiques basés sur des outils. De même, les pointeurs de fonction peuvent sérieusement restreindre les types de contrôles qui peuvent être effectués par les analyseurs statiques et ne devraient être utilisés que s’il y a une justification solide à leur utilisation, et qu’idéalement des moyens de substitution sont fournis pour aider les contrôleurs basés sur des outils à déterminer le flux de contrôle et les hiérarchies d’appel de fonction. Par exemple, si des pointeurs de fonction sont utilisés, il peut devenir impossible pour un outil de prouver l’absence de récursivité, de sorte que d’autres garanties devraient être fournies pour compenser cette perte de capacités analytiques.

II-10 - Règle 10

Tout le code doit être compilé, dès le premier jour de développement, avec tous les avertissements du compilateur activés au niveau le plus « pédant » du compilateur (NDT : pedantic étant une option qui force le plus haut niveau de sévérité d’un compilateur). Tout le code doit être compilé avec ces paramètres sans aucun avertissement. Tout le code doit être vérifié quotidiennement avec au moins un, mais de préférence plusieurs, analyseurs de code source statique de pointe et doit passer les analyses avec zéro avertissement.

Justification : il existe aujourd’hui sur le marché plusieurs analyseurs de code source statique très efficaces, ainsi qu’un certain nombre d’outils gratuits5. Il n’y a tout simplement aucune excuse dans un projet de développement de logiciel pour ne pas utiliser cette technologie facilement disponible. Elle doit être considérée comme une pratique courante, même pour le développement de codes non critiques.
La règle du « zéro avertissement » s’applique même dans les cas où le compilateur ou l’analyseur statique donnent un avertissement erroné : si le compilateur ou l’analyseur statique se trompent, le code qui est à l’origine de la confusion doit être réécrit de manière à ce qu’il soit plus trivial. De nombreux développeurs ont été pris à supposer qu’un avertissement était sûrement invalide, pour se rendre compte bien plus tard que le message était en fait valable pour des raisons moins évidentes. Les analyseurs statiques ont en quelque sorte mauvaise réputation en raison de leurs premiers prédécesseurs, comme lint, qui produisaient surtout des messages non valides, mais ce n’est plus le cas. Les meilleurs analyseurs statiques actuels sont rapides, et ils produisent des messages sélectifs et précis. Leur utilisation ne devrait être négociable dans aucun projet logiciel sérieux.

Les deux premières règles garantissent la création d’une structure de flux de contrôle claire et transparente qui est plus facile à construire, à tester et à analyser. L’absence d’allocation dynamique de la mémoire, stipulée par la troisième règle, élimine une classe de problèmes liés à l’allocation et à la libération de la mémoire, à l’utilisation de pointeurs errants, etc. Les quelques règles suivantes (4 à 7) sont assez largement acceptées comme normes pour un bon style de codage. Ce sont des avantages d’autres styles de codage qui ont été avancés pour les systèmes critiques sécurisés, par exemple la discipline de «programmation par contrat », se retrouve en partie dans les règles 5 à 7.

Ces dix règles sont utilisées à titre expérimental au JPL dans la rédaction de logiciels critiques, avec des résultats encourageants. Après avoir surmonté une saine réticence initiale à vivre dans des limites aussi strictes, les développeurs constatent souvent que le respect des règles a tendance à favoriser la clarté, la possibilité d’analyse et la sécurité du code. Les règles allègent la charge qui pèse sur le développeur et le testeur pour établir par d’autres moyens les propriétés clefs du code (par exemple, la terminaison ou les limites, l’utilisation sûre de la mémoire et de la pile, etc.). Si les règles semblent draconiennes au premier abord, n’oubliez pas qu’elles sont destinées à permettre de vérifier du code là où, littéralement, votre vie peut dépendre de son exactitude : le code qui sert à contrôler l’avion dans lequel vous volez, la centrale nucléaire à quelques kilomètres de chez vous ou le vaisseau spatial qui transporte les astronautes en orbite. Les règles agissent comme la ceinture de sécurité dans votre voiture : au début, elles sont peut-être un peu inconfortables, mais au bout d’un certain temps, leur utilisation devient une seconde nature et ne pas les utiliser devient inimaginable.

III - Remerciements Developpez.com

Nous tenons à remercier watchinofoye pour la traduction, Chrtophe pour la relecture technique et escartefigue pour la relecture orthographique.

IV - Notes

1 Les recherches décrites dans cet article ont été menées au Jet Propulsion Laboratory, California Institute of Technology, dans le cadre d’un contrat avec la National Aeronautics and Space Administration.

2 Note du traducteur : la formulation peut sembler confuse. On parle ici d’un graphe acyclique – donc d’un graphe qui ne comporte aucun cycle – et qui représenterait notre pile d’appel de fonctions. L’intérêt qu’il soit acyclique est qu’on a un moindre risque que notre programme ne se termine jamais – dans le cas d’une récursion mal implémentée, par exemple. C’est en quelque sorte lié au problème de l’arrêt – il ne peut exister un programme qui prédit qu’un programme va s’arrêter ou non.

3 Note du traducteur : dans le cas d’un compilateur configuré dans une norme antérieure à C99, le fait de déclarer les variables locales en milieu de fonction risque de provoquer un avertissement. D’un autre côté, un analyseur syntaxique considérera potentiellement comme un problème le fait que toutes les variables locales soient déclarées au début de la fonction, et il recommandera alors de réduire la portée de celles pour lesquelles il considère que c’est possible. On se retrouve alors avec une contradiction entre le compilateur et l’analyseur syntaxique – qui ne fait que suivre les règles qui lui ont été données.

Pour contourner cette situation, le mieux reste de passer à une norme C99 ou supérieure. Si, pour une raison quelconque, cela n’est pas possible – par exemple, dans le cas de la reprise d’un très ancien projet dont la mise en conformité avec une norme C plus récente serait trop « coûteuse » –, on serait tentés de forcer le respect de la règle au risque d’avoir des avertissements ignorés, mais cela contredirait la règle 10 qui oblige à activer la plus grande sévérité du compilateur et à prendre en compte tous les avertissements. Un ultime recours sera alors d’essayer de redéfinir la règle dans l’analyseur syntaxique pour qu’il prenne en compte la contrainte du compilateur, sous condition que cela soit possible.

Si tous les recours ont échoué, il faudra alors probablement enfreindre la règle 10 en désactivant l’avertissement qui nous gêne via les options du compilateur, si toutefois une telle option existe…

4 Note du traducteur : bien que fort utile, voire indispensable, je confirme que le préprocesseur C est souvent mal compris et mal utilisé, avec parfois des effets très pervers qui ne se manifestent pas toujours sur le court terme. Il faut donc redoubler de prudence lors de son utilisation. Je recommande d’apprendre (en dehors du cadre d’un vrai projet de développement) ses subtilités, qui permettent de réaliser des choses pas forcément utiles, mais qui peuvent être amusantes, bien qu’aberrantes (voir cet article). C’est également un bon moyen pour identifier les bons et les mauvais usages du préprocesseur.

5 Pour un aperçu, voir, par exemple, http://spinroot.com/static/index.html

Note du traducteur : la liste fournie par l’auteur ne mentionne pas le très bon Cppcheck, que je vous recommande. Il est libre et gratuit.