La sandbox applicative : apprendre à vivre en cage
On ne peut pas ouvrir un fichier sans demander la permission. À chaque fois.
Le Mac App Store exige le sandboxing. Sans l’entitlement de sandbox, l’application est rejetée à la soumission. La sandbox est le modèle de sécurité d’Apple pour les logiciels Mac distribués : un inconvénient modéré pour la plupart des applications de productivité, une contrainte de fond pour un analyseur de disque.
Le boulot de Renala, c’est de lire le système de fichiers. La sandbox répond : pas sans permission.
La sandbox ne se contente pas de limiter ce que l’application peut faire : elle limite ce qu’on peut voir. Chaque bug décrit dans cet article est une erreur de perception, pas une erreur de code.
Ce que fait la sandbox
Un environnement restreint pour un seul processus : là où Docker isole les processus les uns des autres, l’App Sandbox restreint ce qu’un seul d’entre eux peut atteindre, sous le contrôle du noyau macOS. Une application sandboxée reçoit sa propre tranche de système de fichiers sous ~/Library/Containers/<bundle-id>/Data/. Préférences, caches, fichiers Application Support : tout vit là-dedans, pas dans le vrai répertoire personnel.
Par défaut, une application sandboxée ne peut pas lire de fichiers arbitraires : impossible d’accéder à un fichier sans autorisation explicite de l’utilisateur, quel que soit le volume, et certains répertoires système lui sont tout bonnement invisibles.
Le modèle de sécurité, correctement implémenté, est solide. Une application sandboxée compromise ne peut pas lire vos clés SSH, accéder à vos mails, ni toucher aux données des autres applications. La contrepartie, c’est que les applications légitimes, analyseurs de disque compris, doivent ramer pour obtenir ce que les applications non sandboxées ont d’emblée. Un analyseur de disque qui ne peut pas lire le disque, c’est un énoncé philosophique, pas un produit.
Le piège NSHomeDirectory
Tôt dans le développement, le code de détection des disques cloud vérifiait si un chemin commençait par le répertoire personnel de l’utilisateur. La logique utilisait NSHomeDirectory() pour obtenir ce chemin. Approche raisonnable, environnement piégé.
Dans une application sandboxée, NSHomeDirectory() renvoie le chemin du conteneur : /Users/vous/Library/Containers/com.renala.app/Data/, pas /Users/vous/. Le vrai répertoire personnel est invisible pour cette API.
Toutes les vérifications de chemins cloud échouaient en silence. Les chemins iCloud passent par /Users/vous/Library/Mobile Documents/, ceux de Google Drive par /Users/vous/Library/CloudStorage/. Aucun ne commence par le chemin du conteneur. Le code de détection cloud était un no-op pour tous les utilisateurs. Ni plantage, ni erreur, ni log. Faux de bout en bout, sans un bruit.
Le code passait tous les tests que j’avais écrits, parce que j’avais écrit les tests dans le même mauvais environnement. Les tests étaient corrects. C’est l’environnement qui mentait. Le genre d’échec de test le plus dangereux : un test qui passe pour la mauvaise raison. Le remède n’est pas d’écrire plus de tests, mais de se demander si l’environnement de test partage la même distorsion que le code en production.
La correction : ne plus utiliser NSHomeDirectory() du tout. Vérifier si le chemin contient "/Library/CloudStorage/" ou "/Library/Mobile Documents/" : recherche de sous-chaîne, pas correspondance de préfixe. Le vrai chemin contiendra ces segments quel que soit le préfixe renvoyé.
Les security-scoped bookmarks
Quand l’utilisateur sélectionne un répertoire via NSOpenPanel, l’application reçoit une URL avec un accès valide pour la session en cours. Fermer l’application et la relancer suffit : l’URL n’est plus qu’une chaîne de caractères, l’accès a disparu.
Pour rouvrir le même répertoire après un redémarrage sans redemander la permission, il faut un security-scoped bookmark. Quand l’utilisateur accorde l’accès, on appelle url.bookmarkData(options: .withSecurityScope, ...) pour stocker le blob résultant. Au lancement suivant, URL(resolvingBookmarkData:) reconstruit l’URL, et url.startAccessingSecurityScopedResource() réactive la portée.
flowchart TD
accTitle: Security-scoped bookmark lifecycle
accDescr: The user picks a folder in NSOpenPanel, granting a URL with security scope. The app can persist this as bookmark data and access files via startAccessingSecurityScopedResource. On next launch, the bookmark is resolved to restore access. Stale bookmarks trigger re-authorization.
A["L'utilisateur choisit un répertoire<br/>dans NSOpenPanel"] -->|"accorde l'accès"| B["URL + portée de sécurité<br/>active pour la session"]
B -->|"url.bookmarkData(...)"| C["Blob du bookmark<br/>persisté sur disque"]
B -->|"startAccessingSecurityScopedResource"| D["Accès aux fichiers"]
D -->|"stopAccessingSecurityScopedResource"| E["Portée libérée"]
C -->|"prochain lancement"| F["URL(resolvingBookmarkData:)"]
F -->|"startAccessingSecurityScopedResource"| D
F -->|"périmé ou invalide"| G["Nouvelle autorisation<br/>boîte de dialogue"]
L’accès n’est pas gratuit. Chaque appel à startAccessingSecurityScopedResource() incrémente un compteur de portées suivi par le noyau ; chaque appel à stopAccessingSecurityScopedResource() le décrémente. Si on oublie l’appel de libération, le compteur ne fait que monter. Quelques dizaines d’appels non compensés, et startAccessingSecurityScopedResource() se met à renvoyer false. L’application perd silencieusement l’accès à tous les emplacements bookmarkés. Ni plantage, ni boîte de dialogue : l’application devient aveugle.
Renala gère ça par un accès gardé : chaque startAccessingSecurityScopedResource() est couplé à un appel stopAccessingSecurityScopedResource(), de sorte que la portée est libérée à la fin de l’opération de scan. Le pattern est le même que pour la fermeture de descripteurs de fichiers ou la libération de verrous : acquérir, travailler, libérer. La discipline est banale ; le mode de défaillance sans elle ne l’est pas.
Le cycle de vie des bookmarks n’est pas qu’un fardeau ergonomique. En 2025, Microsoft a découvert que l’entrée du trousseau stockant le secret de signature des bookmarks (com.apple.scopedbookmarksagent.xpc) n’avait une protection ACL qu’en lecture, pas contre la suppression. Un attaquant sandboxé pouvait supprimer le secret, en créer un nouveau avec une valeur connue, forger des signatures de bookmark, et obtenir de ScopedBookmarkAgent qu’il accorde l’accès à des fichiers arbitraires sans interaction utilisateur (CVE-2025-31191, corrigé par Apple). Le mécanisme de persistance est devenu la porte de sortie. Ce qu’on gère ici n’est pas qu’une ressource : c’est une surface d’attaque.
Quand ça marche, le système de bookmarks est invisible. L’utilisateur accorde l’accès une seule fois et n’y pense plus. Don’t Make Me Think s’applique à l’expérience de sécurité autant qu’à la navigation. Une invite de permission qui surgit sans raison apparente est perçue comme un bug, même quand c’est techniquement le comportement attendu. Un utilisateur qui branche trois disques externes doit accorder l’accès à chacun individuellement. Pour un outil dont la raison d’être est « aidez-moi à comprendre ce qu’il y a sur tous mes disques », la gêne s’additionne vite.
L’entitlement lecture-écriture
D’abord l’API a menti sur l’emplacement du répertoire personnel. Maintenant le modèle de permissions est en désaccord avec l’application elle-même sur ce qu’elle fait.
L’entitlement de sandbox pour l’accès aux fichiers propose deux modes : lecture seule et lecture-écriture. Un analyseur de disque ne fait que lire des fichiers : lecture seule, choix évident. Empreinte plus légère, posture de sécurité plus propre.
Et puis est arrivé « Placer dans la Corbeille ».
FileManager.trashItem(at:resultingItemURL:) déplace un fichier vers la Corbeille et exige un accès en écriture. Avec un entitlement en lecture seule, l’appel lève une erreur de permission. Je l’ai découvert pendant les tests, pas dans la documentation. L’échec n’est pas silencieux comme le bug NSHomeDirectory(), mais il ne se manifeste que si on teste la corbeille avec le mauvais entitlement. Même solution : une permission plus large.
Le compromis : demander l’entitlement lecture-écriture. Plus large que ce qui est strictement nécessaire pour l’analyse, certes, mais un analyseur de disque qui se contente de montrer le problème sans laisser l’utilisateur le résoudre reste un outil de diagnostic sans remède. Personne n’en veut.
graph TD
accTitle: App sandbox entitlements hierarchy
accDescr: The app-sandbox entitlement is the root. It branches into read-write file access (required for Move to Trash) or read-only file access. Both variants connect to the bookmarks entitlement for persistent access across launches.
A["Entitlement App Sandbox<br/>com.apple.security.app-sandbox"] --> B["Entitlement lecture-écriture<br/>com.apple.security.files.user-selected.read-write<br/>(requis pour Placer dans la Corbeille)"]
A --> C["Entitlement fichiers sélectionnés<br/>com.apple.security.files.user-selected.read-only<br/>(variante lecture seule)"]
B --> D["Entitlement bookmarks<br/>com.apple.security.files.bookmarks.app-scope<br/>(accès persistant entre lancements)"]
C --> D
Le retour d’un ami
La sandbox ne cache pas seulement des fichiers à l’application : elle cache des problèmes au développeur. On teste depuis l’intérieur de la distorsion.
Un ami, designer graphique et spécialiste UX, a testé l’application sur son Mac Intel. Son retour écrit contenait ceci : « J’ai l’impression qu’elle n’analyse pas les fichiers cachés. Il faudrait peut-être ajouter une option ? Par ailleurs, on ne peut même pas cibler un dossier caché directement pour l’analyser. »
Il avait raison sur les deux points. Le scanner passe .skipsHiddenFiles à l’énumérateur de fichiers ; .Trash, .config et les répertoires similaires sont donc absents des résultats. La sandbox aggrave le problème : le sélecteur de fichiers standard ne montre pas les éléments cachés, si bien qu’un utilisateur qui voudrait analyser un dossier caché n’a aucun moyen d’y naviguer via l’interface.
Ajouter un bouton « afficher les fichiers cachés » au scanner est simple. Permettre de cibler un dossier caché via le sélecteur de fichiers exige de configurer le sélecteur explicitement, et la plupart des utilisateurs ne s’attendraient pas à en avoir besoin. Des limitations connues, certes, mais ce retour a montré qu’elles ne passent pas inaperçues : un designer les a repérées dès la première session.
Vivre avec les contraintes
La sandbox a restreint l’API : NSHomeDirectory() renvoyait le chemin du conteneur, et la recherche défensive par sous-chaîne a remplacé la correspondance par préfixe. Elle a restreint les tests : le code passait tous les tests parce que les tests partageaient la même distorsion que l’environnement de production. Elle a restreint l’interface : un observateur extérieur a repéré ce que le développeur, testant depuis l’intérieur de la cage, ne pouvait pas voir.
Quand on vit assez longtemps à l’intérieur d’une contrainte, on finit par ne plus la voir. La seule parade fiable, c’est la vérification depuis l’extérieur : un environnement de test différent, un autre regard, une hypothèse différente sur ce que fait sa propre application.
L’App Store exige la sandbox. Les détails d’implémentation sont laissés en exercice au lecteur.
Références
- Apple: App Sandbox overview : concepts, structure du conteneur et référence des entitlements.
- Apple: startAccessingSecurityScopedResource : activation des security-scoped bookmarks et cycle de vie des portées.
- WWDC 2012: The OS X App Sandbox : plongée détaillée dans la conception de la sandbox, les conteneurs et les entitlements.
- Apple: Entitlements reference : liste complète des clés d’entitlement et leurs effets.
- Microsoft Security Blog: CVE-2025-31191 : analyse de l’évasion de sandbox macOS par manipulation de la clé de signature des security-scoped bookmarks.
