Interpolation shell : empêchez les entrées d'agent de devenir des commandes
L'interpolation shell peut transformer les chemins et noms de ressources contrôlés par un agent en syntaxe exécutable. Construisez des actions locales et SSH plus sûres avec des tableaux d'arguments et une validation stricte.

Un agent n'a pas besoin d'un outil shell pour déclencher l'exécution d'un shell. Donnez-lui un chemin, une branche, un tag d'image, un nom d'hôte ou un nom de ressource, puis insérez cette valeur dans une chaîne de commande quelque part en aval : vous donnez à un texte non fiable la possibilité de modifier le programme exécuté.
La solution n'est pas une fonction d'échappement plus ingénieuse. Il faut cesser de considérer la construction de commandes comme un simple formatage de chaînes. Une action doit désigner une opération fixe, accepter des champs typés avec des règles strictes, appeler un programme avec un véritable tableau d'arguments et séparer l'autorisation du parsing. J'ai lu suffisamment de comptes rendus d'incidents pour être direct : un outil qui accepte une commande libre parce qu'il est « réservé à notre agent » finira aussi par accepter les instructions de quelqu'un d'autre.
Les agents rendent cette erreur plus facile à atteindre. Ils consomment du texte d'issues, le contenu de dépôts, la sortie d'outils, des pages web et des messages de discussion. Chacune de ces sources peut influencer l'argument suivant. Que l'agent ait voulu nuire ou non ne change rien. Toute frontière qui atteint un système d'exploitation ou un hôte distant doit partir du principe qu'un attaquant peut façonner ses entrées.
L'interpolation shell transforme les données en syntaxe
L'interpolation shell se produit lorsque le code construit du texte pour un interpréteur de commandes au lieu de fournir des arguments séparés à une API de processus. Une fois ce texte reçu par un shell, celui-ci applique ses propres règles de langage. Les séparateurs peuvent lancer une autre commande, les substitutions de commandes peuvent en exécuter une nouvelle, les redirections peuvent modifier des fichiers et les expansions peuvent transformer une valeur apparente en plusieurs mots.
Supposons qu'un agent doive inspecter une branche avant une mise en production. Ce gestionnaire semble innocent :
branch = request["branch"]
command = f"git show {branch}"
subprocess.run(command, shell=True, check=True)
Si branch vaut release; id, le shell voit deux commandes. La première demande à Git d'afficher une révision. La seconde exécute id. La même erreur apparaît en JavaScript avec exec, en Ruby avec une chaîne passée à system, en Go avec sh -c et dans les scripts CI qui placent directement la sortie d'un agent dans une variable shell sans contrôler son utilisation ultérieure.
Le caractère dangereux n'a pas besoin d'arriver dans le prompt de l'agent. Il peut se trouver dans le titre d'une pull request, un message d'erreur, un manifeste de paquet ou un nom de fichier renvoyé par l'analyse d'un dépôt. L'agent peut reprendre ce contenu dans un appel d'outil ultérieur parce que sa tâche lui demande de l'inspecter ou de le corriger. Une revue qui vérifie seulement si le modèle suit une instruction manque l'endroit où le texte devient une syntaxe exécutable.
Le langage de commande du shell POSIX décrit une séquence qui comprend les expansions, la séparation des champs dans les cas applicables, l'expansion des noms de fichiers et la suppression des guillemets. L'ordre compte. Un développeur dit souvent : « Je l'ai entouré de guillemets », comme si les guillemets créaient une chaîne générique sûre. Ce n'est pas le cas. Ils limitent certaines règles de parsing dans un shell donné, à un moment donné de sa séquence d'expansion.
Un shell peut aussi recevoir des données de plusieurs façons. Une valeur peut modifier le texte de la commande lui-même, une affectation d'environnement, une cible de redirection, une substitution de commande, un fichier de démarrage du shell ou du code évalué ensuite par un autre interpréteur. L'échappement prévu pour le premier shell ne protège pas automatiquement contre l'analyseur suivant.
Gardez cette distinction nette :
- L'injection de commandes se produit lorsque l'entrée modifie une syntaxe interprétée par un langage de commande.
- L'injection d'arguments se produit lorsque l'entrée reste un seul argument, mais modifie la façon dont le programme appelé l'interprète, souvent comme une option ou une expression.
- Une défaillance d'autorisation se produit lorsqu'un argument valide demande une action hors du périmètre autorisé par l'appelant.
Un tableau d'arguments empêche la première catégorie. Il ne résout pas les deux autres. Un chemin qui sort d'un espace de travail, une révision Git qui sélectionne un objet imprévu et une URL qui atteint un service interne peuvent tous être un argument parfaitement séparé.
Une chaîne de commande entre guillemets comporte toujours trop d'analyseurs
Mettre une entrée entre guillemets avant de la concaténer à une commande shell est courant parce que cela semble être une petite correction. C'est aussi fragile, car la commande traverse souvent plusieurs grammaires.
Supposons qu'un programme local construise un appel SSH et que le système distant exécute un shell. Un développeur peut protéger la branche pour le shell local, la protéger une seconde fois pour SSH, puis une troisième fois pour le shell distant. Chaque couche repose sur des hypothèses différentes concernant les espaces, les guillemets, les antislashs, les substitutions de commandes et l'encodage. Une modification ultérieure peut supprimer une couche de protection ou placer la chaîne déjà protégée dans un nouveau contexte. Le code semble toujours prudent alors que la protection a disparu.
Ce modèle est particulièrement mauvais :
const command = `git checkout '${branch}'`;
execFile("ssh", [host, command], callback);
execFile protège l'appel local à ssh, ce qui est une bonne chose. Il ne rend pas command sûre sur le système distant. SSH fournit normalement au système distant une chaîne de commande. Le compte distant la remet généralement à un shell. La branche entre donc dans la syntaxe du shell un saut plus tard.
Les guillemets simples ne sont pas une solution générale. Une entrée qui contient un guillemet simple peut fermer la région prévue. Une fonction d'échappement écrite pour un shell POSIX est incorrecte pour PowerShell. Une fonction correcte pour un mot shell devient incorrecte lorsque la valeur arrive dans une redirection, une expression arithmétique ou un interpréteur de langage. Une fonction correcte aujourd'hui reste une charge de maintenance si quelqu'un remplace git show par git log --format=... et réorganise le modèle.
Ne confondez pas la sérialisation d'une liste d'arguments à des fins de journalisation avec sa reconstruction pour l'exécution. Une ligne comme git show release-42 est utile à un humain, mais elle ne permet pas de savoir où se trouvaient les limites originales entre les arguments. Ce n'est pas une représentation exécutable sûre. Conservez les arguments sous forme de tableau structuré et ne les affichez qu'avec des règles d'échappement explicites.
Les variables d'environnement constituent un autre piège. C'est plus sûr que l'interpolation :
BRANCH="$branch" git show "$BRANCH"
mais cela ne reste sûr que si le script shell contrôle les deux expansions protégées et n'évalue jamais la valeur plus tard. Si un script en aval utilise eval, construit une autre chaîne de commande ou transmet la valeur à un moteur de modèles doté de fonctions d'exécution, le travail initial aura été largement inutile. Une meilleure frontière évite de remettre la valeur de l'agent à un shell.
Les exécutables fixes et les tableaux d'arguments suppriment la grammaire du shell
Pour les actions locales, appelez un exécutable fixe avec un tableau d'arguments et laissez l'exécution par shell désactivée. Le système d'exploitation transmet alors au processus enfant les chaînes individuelles que vous avez fournies. Il ne réinterprète pas les points-virgules, les signes dollar, les espaces, les parenthèses ou les caractères génériques comme un langage shell.
La documentation Python de subprocess recommande une séquence d'arguments et indique que shell=False est la valeur par défaut. Utilisez cette valeur par défaut délibérément, au lieu de vous en remettre au hasard :
import subprocess
result = subprocess.run(
["git", "status", "--porcelain=v1"],
cwd=repo_dir,
text=True,
capture_output=True,
check=True,
)
print(result.stdout)
Cette action ne laisse à l'agent le contrôle ni de l'exécutable ni du modèle de commande. Elle réalise une opération connue dans un répertoire approuvé. La sortie est du texte brut, avec un enregistrement d'état par ligne. Vous pouvez encore améliorer l'interface en analysant cette sortie et en renvoyant des enregistrements structurés, au lieu de transmettre du texte brut à une autre décision de l'agent.
Lorsqu'une opération a besoin d'une entrée, gardez l'exécutable et l'opération fixes, puis ajoutez les valeurs validées comme éléments séparés :
subprocess.run(
["tool", "fetch-resource", resource_id],
cwd=workspace,
check=True,
shell=False,
)
Dans Node.js, préférez spawn ou execFile avec un tableau d'arguments. N'activez pas l'option shell pour rendre une commande composée plus pratique. En Go, utilisez exec.Command en fournissant chaque argument séparément. En Rust, utilisez Command et plusieurs appels à arg. Les API diffèrent, mais l'invariant reste le même : aucune entrée ne doit entrer dans un langage de commande.
C'est le bon moment pour contester une recommandation fréquente : « Utilisez un script shell comme enveloppe sûre. » Un script shell fixe peut constituer une frontière de compatibilité acceptable, mais il n'est pas plus sûr par nature. Il ne le devient que lorsqu'il reçoit les valeurs par des paramètres positionnels fixes ou l'entrée standard, protège chaque expansion, évite eval et ne construit pas une seconde chaîne de commande. Un petit programme utilisant une API de processus est généralement plus facile à auditer.
Les shells offrent des fonctions légitimes : pipelines, branchements conditionnels, redirections et commandes intégrées. Placez ces fonctions dans un script statique revu lorsque vous ne pouvez pas les supprimer. Ne laissez pas l'agent assembler le pipeline. Donnez-lui une action nommée collect_build_logs, puis laissez l'enveloppe exécuter son pipeline fixe avec des emplacements de fichiers fixes et des paramètres bornés.
L'exécutable lui-même doit aussi faire partie de la politique. Ne le choisissez jamais à partir de l'entrée de l'agent, d'un fichier de configuration modifiable ou d'une recherche dans un PATH contrôlé par un attaquant. Utilisez un chemin absolu contrôlé par l'administrateur lorsque votre environnement l'exige. Définissez un répertoire de travail connu et un environnement réduit au lieu d'hériter de variables arbitraires du processus de l'agent.
La validation définit ce que l'action peut signifier
Les tableaux d'arguments protègent la frontière du parsing. La validation protège la frontière de l'action. Si une action s'appelle read_file, le service doit décider quels fichiers peuvent être lus. Une API de processus ne peut pas prendre cette décision à votre place.
Commencez par un schéma strict. Un nom de déploiement peut contenir des lettres minuscules, des chiffres et des séparateurs internes uniques, avec une limite de longueur. Un sélecteur de branche peut être un nom de branche correspondant à un sous-ensemble choisi des règles de Git. Un identifiant de ressource peut être un UUID ou un entier issu d'un inventaire. Une cible serveur peut être un identifiant que votre service associe à un hôte enregistré, plutôt qu'un nom d'hôte arbitraire.
Évitez les listes noires génériques comme « refuser le point-virgule et l'esperluette ». Elles sont construites autour d'un seul analyseur et ne traitent pas les abus sémantiques. Elles s'allongent aussi jusqu'à rendre imprévisible ce qui fonctionne pour les utilisateurs. Une grammaire en liste blanche fournit un contrat à l'appelant et un ensemble limité de valeurs à examiner pour la personne qui fait la revue.
Pour un champ de branche dans un workflow de mise en production, une grammaire volontairement limitée peut être préférable à l'acceptation de toutes les références permises par Git :
import re
BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/]{0,127}")
def release_ref(value: str) -> str:
if not isinstance(value, str):
raise ValueError("branch must be text")
if not BRANCH.fullmatch(value):
raise ValueError("branch has unsupported characters")
if "/./" in value or "//" in value or value.endswith("/"):
raise ValueError("branch has an unsupported path form")
return "refs/heads/" + value
Ce code ne prétend pas implémenter la grammaire complète des références Git. Il définit volontairement une grammaire plus petite pour une action de mise en production. C'est souvent le bon choix technique. Si les utilisateurs ont besoin d'espaces ou d'une autre forme inhabituelle, ajoutez-la parce que le workflow l'exige, testez-la et documentez-la. N'héritez pas de chaque cas limite d'un système général de contrôle de version lorsque votre action ne déploie que des branches de mise en production.
La valeur renvoyée possède un préfixe d'espace de noms fixe. C'est important. Une expression de révision arbitraire peut désigner des tags, l'ascendance d'un commit, des reflogs ou une syntaxe que Git interprète spécialement. Une action de branche de mise en production ne doit pas devenir discrètement « afficher n'importe quel objet Git que l'agent peut décrire ». Utilisez une bibliothèque ou le mode de validation des branches de Git lorsque vous avez besoin d'une compatibilité complète avec Git, puis associez malgré tout le résultat accepté à l'espace de noms autorisé.
Les noms de ressources exigent la même discipline. Si un agent demande un objet cloud, stockez le compte, la région, le bucket ou le projet autorisé en dehors du champ libre. Laissez-le fournir un nom d'objet qui respecte l'unique grammaire prise en charge. N'acceptez pas une URL complète en l'appelant nom de ressource. Une URL complète choisit le protocole, l'hôte, le port, le chemin et parfois les identifiants. C'est une décision d'autorisation réseau déguisée en chaîne de caractères.
Les limites de longueur font partie de la validation. Elles plafonnent la croissance des journaux, évitent les limites accidentelles de ligne de commande et rendent les dénis de service moins commodes. Les règles d'encodage des caractères en font aussi partie. Refusez les caractères de contrôle dans les champs textuels, sauf si l'action en a réellement besoin. Refusez toujours NUL dans les arguments de processus, car les interfaces de processus des systèmes d'exploitation ne peuvent pas le transporter comme partie d'un argument.
Les chemins exigent des contrôles de confinement, pas des filtres de caractères
Un chemin peut être sûr pour le shell et dangereux pour le système de fichiers. ../../secrets.env ne contient aucun métacaractère shell qui inquiéterait une fonction de protection. Il peut tout de même déplacer une opération de fichier en dehors de l'espace de travail.
Une politique de chemin sûre commence par la déclaration d'une racine pour l'action. Résolvez le chemin relatif demandé par rapport à cette racine, puis vérifiez que la cible résolue reste dans la racine elle-même résolue. N'acceptez pas de chemin absolu lorsque l'action ne nécessite que des fichiers de l'espace de travail. Ne normalisez pas une chaîne en supposant que son écriture obtenue indique où le système de fichiers ira réellement.
Un contrôle conceptuel ressemble à ceci :
from pathlib import Path
workspace = Path("/srv/agent-workspaces/repo-a").resolve()
def permitted_file(relative_name: str) -> Path:
if not isinstance(relative_name, str) or relative_name.startswith("/"):
raise ValueError("file must be a relative path")
candidate = (workspace / relative_name).resolve(strict=True)
if candidate == workspace or workspace not in candidate.parents:
raise ValueError("file is outside the workspace")
if not candidate.is_file():
raise ValueError("requested target is not a regular file")
return candidate
L'appel à resolve détecte les traversées ordinaires et suit les liens symboliques existants, ce qui vaut mieux que de vérifier si la chaîne brute contient ... Cela ne suffit toujours pas pour une opération d'écriture sensible. Un attaquant qui peut modifier l'arborescence peut remplacer un composant du chemin par un lien symbolique après le contrôle et avant l'ouverture ultérieure. Cet intervalle entre le contrôle et l'utilisation constitue une course de type TOCTOU.
Pour les opérations de lecture dans un espace de travail contrôlé, un contrôle de confinement après résolution peut correspondre à votre niveau de risque. Pour les écritures, suppressions, changements de permissions ou archives que des attaquants peuvent influencer, utilisez des opérations de système de fichiers qui conservent les descripteurs de répertoire et refusent le parcours de liens symboliques lorsque le système d'exploitation le permet. Séparez les espaces de travail modifiables par l'agent du code qui décide des racines autorisées. Si votre modèle de menace inclut un attaquant local capable de provoquer une course sur le système de fichiers, une bibliothèque de chemins de haut niveau ne suffit pas.
Décidez aussi si les fichiers cachés, les métadonnées du dépôt, les répertoires de dépendances générés et les liens symboliques font partie du périmètre. Une règle vague comme « tout ce qui se trouve sous le dépôt » expose souvent des fichiers d'identifiants, des caches de compilation ou une configuration dont le workflow n'avait pas besoin. Le droit de lecture doit découler du but de l'action, pas de la commodité d'un glob récursif.
L'extraction d'archives mérite un avertissement séparé. Validez chaque membre de l'archive après l'avoir associé à la racine de destination et considérez les liens présents dans une archive comme potentiellement hostiles. La traversée de chemins ne se limite pas aux requêtes faites directement par l'agent. Un outil qui extrait un artefact choisi par l'agent peut provoquer la même sortie de périmètre en son nom.
Les options et les expressions restent dangereuses sans shell
Un tableau d'arguments sépare une valeur de la syntaxe du shell, mais le programme appelé continue d'analyser cette valeur. Les programmes traitent souvent les arguments commençant par un tiret comme des options. Certains acceptent des expressions, des références de configuration, une syntaxe d'inclusion de fichiers, des extensions ou des hooks de commande. Transmettre une entrée non fiable comme un seul argument ne rend pas ces significations inoffensives.
Prenez une enveloppe qui appelle un programme de recherche avec un motif fourni par l'utilisateur. L'enveloppe peut fournir correctement ["search", pattern, directory]. Si le programme traite le motif comme une expression régulière, une expression pathologique peut consommer énormément de processeur. S'il propose une option qui lit un fichier de configuration et que le motif arrive à la mauvaise position, l'appelant peut modifier le comportement du programme. Si le langage du programme autorise l'évaluation de code, vous avez remis à l'agent un autre interpréteur.
Placez les options fixes avant les données et utilisez le marqueur conventionnel de fin des options lorsque le programme le prend en charge. En toutes lettres, ce marqueur contient deux caractères tiret. Il indique à de nombreux analyseurs d'arguments que les valeurs suivantes sont des opérandes et non des options. Ne vous en remettez pas à lui pour la validation. Certains outils ne le respectent pas et un opérande peut rester une valeur sémantiquement dangereuse.
Git possède une large surface de commandes, raison pour laquelle les agents ont besoin d'actions Git limitées plutôt que d'une échappatoire générique « exécuter Git ». Une action show_release_branch peut accepter un nom de branche restreint, le convertir en refs/heads/ suivi de ce nom et appeler une sous-commande Git fixe. Une action checkout_anything qui accepte une syntaxe de révision arbitraire a une signification plus large et exige une décision d'autorisation plus large. Ce sont deux produits différents, même s'ils lancent tous deux un processus Git.
La même règle s'applique aux gestionnaires de paquets, clients de bases de données, outils multimédias, archiveurs et commandes d'infrastructure. Demandez-vous quelle grammaire le programme cible analysera après que le système d'exploitation lui aura remis l'argument. Une ligne de commande n'est souvent que le premier analyseur d'une chaîne.
Les expressions régulières demandent une attention particulière. Un motif n'est pas du code shell, mais c'est un travail exécutable au sein d'un moteur d'expressions régulières. Si l'agent doit rechercher du texte, choisissez si possible un moteur aux performances prévisibles, imposez des limites au motif et aux entrées et faites de la recherche littérale l'action par défaut. Ne transformez pas chaque demande de recherche en langage d'expressions sans restriction simplement parce qu'un développeur veut un endpoint flexible.
SSH transforme une commande locale en problème de parsing distant
SSH crée une seconde frontière d'exécution. Vous pouvez appeler le client SSH local avec un tableau d'arguments parfait et tout de même envoyer une chaîne de commande dangereuse à l'hôte distant. De nombreuses implémentations de SSH exécutent la commande distante demandée via le shell du compte distant ou un analyseur de commandes équivalent.
Ne construisez pas de commandes distantes à partir de champs fournis par l'agent. Ceci est incorrect même si l'appel local utilise un tableau :
remote = "deploy " + environment + " " + branch
subprocess.run(["ssh", host, remote], check=True)
Une disposition sûre envoie une commande distante constante et transporte les données dans un canal structuré. Par exemple, le compte distant peut exposer un programme révisé à un chemin fixe. Le côté local démarre ce programme fixe, envoie une requête JSON sur l'entrée standard et le programme distant valide environment et branch avant d'exécuter toute opération locale.
import json
import subprocess
request = {"environment": "staging", "branch": "release/42"}
subprocess.run(
["ssh", "deploy.internal", "/usr/local/libexec/receive-deploy-request"],
input=json.dumps(request) + "\n",
text=True,
check=True,
)
La commande distante de cet exemple est constante. Le JSON est une donnée transmise par l'entrée standard, pas du texte intégré à la commande distante. Le récepteur distant doit tout de même refuser les champs inconnus, appliquer des limites de longueur, valider chaque valeur et utiliser un tableau d'arguments pour tout processus enfant. Le transport structuré supprime le labyrinthe des guillemets. Il n'accorde pas la confiance.
Le nom d'hôte devrait généralement être choisi dans une configuration enregistrée à partir d'un identifiant opaque d'environnement. Laisser un agent fournir host lui permet de choisir où vont les identifiants et quelle frontière réseau l'action franchit. Si un workflow a réellement besoin de plusieurs hôtes, associez des noms approuvés comme staging et production à des paramètres de connexion connus. Gardez la vérification de l'hôte active. Un outil qui la désactive pour éviter les difficultés de configuration a supprimé une partie importante de la frontière.
N'envoyez pas d'identifiants dans la commande distante, la ligne de commande ou la requête JSON. Utilisez le dispositif d'authentification existant du compte distant et limitez ce compte à l'action qu'il doit effectuer. Un récepteur de déploiement qui ne peut déployer qu'une application est plus facile à comprendre qu'un agent qui reçoit une session shell largement autorisée.
L'approbation ne peut pas réparer une conception d'action dangereuse
L'approbation humaine et une piste d'audit sont des contrôles utiles, mais ils répondent à des questions différentes de la validation. L'approbation indique si cet appelant peut tenter une action maintenant. La validation indique si la requête possède une forme définie et autorisée. La journalisation indique ce qui s'est produit et si l'enregistrement a changé par la suite. Traiter l'un de ces éléments comme un substitut aux autres produit des outils faibles derrière des écrans séduisants.
Une approbation de session unique peut convenir à une passerelle d'actions fixe. Elle ne constitue pas une revue attentive de chaque chaîne interpolée qu'un processus transmettra à un shell pendant le reste de la session. Une approbation par appel donne davantage d'occasions à une personne de remarquer une cible étrange, mais personne ne devrait avoir à vérifier des règles de guillemets imbriquées ou à repérer un sosie Unicode malveillant dans une boîte de dialogue dense.
Faites apparaître dans la carte d'approbation la requête sémantique : deploy branch release/42 to staging, et non une commande shell reconstruite à partir d'un modèle. Le récepteur doit pouvoir comparer l'environnement et la branche affichés au schéma de la requête. Si une commande a besoin d'un champ de script brut pour s'expliquer, l'action est trop large pour être approuvée de manière fiable.
Journalisez des faits structurés à la frontière de l'action. Conservez l'identité de l'appelant ou du processus de l'agent, le nom de l'action approuvée, les arguments ou champs de requête séparés, la cible sélectionnée, les horodatages, le statut de sortie et les références vers la sortie, conformément à vos règles de conservation des données. Enregistrez aussi les échecs de validation. Des chaînes de traversée refusées à répétition ou des valeurs ressemblant à des options révèlent souvent un mauvais prompt, une erreur d'intégration ou une phase de sondage active.
Pour les équipes qui utilisent Sallyport, l'autorisation par session, l'approbation par clé à chaque appel et ses journaux séparés Sessions et Activity peuvent préserver le contrôle et les preuves autour des actions des agents, mais le modèle de commande doit toujours respecter les règles de conception ci-dessus.
Un journal infalsifiable n'aide après un incident que s'il enregistre les frontières qui comptaient. Une chaîne comme ssh deploy internal deploy staging release 42 est ambiguë après coup. Un événement contenant séparément l'identité de l'hôte, le chemin fixe du récepteur, les champs JSON, le résultat de l'autorisation et le statut de sortie du récepteur peut aider à mener une enquête.
Testez le rejet avec autant de rigueur que le cas nominal
La plupart des tests d'injection de commandes prouvent seulement qu'une branche normale comme release/42 fonctionne. C'est l'entrée la moins intéressante. La suite de tests doit prouver que le gestionnaire refuse les valeurs dangereuses avant de démarrer un processus enfant ou d'ouvrir un chemin.
Construisez un tableau pour chaque type de champ. Les entrées exactes dépendent de la grammaire, mais les catégories devraient inclure les séparateurs shell, les substitutions de commandes, les guillemets, les espaces et retours à la ligne, les préfixes ressemblant à des options, les composants de traversée, les valeurs vides, les caractères de contrôle, les valeurs trop longues et les syntaxes valides qui demandent quelque chose hors du périmètre de l'action. Pour une action de branche, testez aussi un nom ressemblant à un tag et une expression de révision que votre interface ne prend pas en charge.
Utilisez un exécuteur de processus factice dans les tests unitaires. Vérifiez l'exécutable et chaque argument comme des valeurs séparées. Vérifiez qu'une entrée invalide n'appelle jamais l'exécuteur. Un test qui compare seulement une chaîne de commande affichée peut manquer précisément la frontière que vous cherchez à protéger.
class RecordingRunner:
def __init__(self):
self.calls = []
def run(self, argv, **kwargs):
self.calls.append((argv, kwargs))
def test_rejects_branch_separator():
runner = RecordingRunner()
try:
release_ref("release/42; id")
except ValueError:
pass
else:
raise AssertionError("expected rejection")
assert runner.calls == []
Ce test vérifie la fonction de politique, pas le shell. Ajoutez aussi un test d'intégration de la frontière de processus. Exécutez un programme de test inoffensif qui affiche ses arguments reçus avec des index et des longueurs explicites. Fournissez-lui des valeurs contenant des espaces, des guillemets, des caractères génériques, des parenthèses et des retours à la ligne. Vérifiez que chaque valeur arrive comme un seul argument et qu'aucun second programme ne démarre.
Le fuzzing est utile une fois que l'action possède un schéma. Générez des caractères Unicode aléatoires, à l'intérieur et à l'extérieur de l'ensemble accepté, puis vérifiez l'un des deux résultats suivants : le validateur refuse la valeur ou l'opération fixe la reçoit comme un champ unique et borné. Ne faites pas de fuzzing contre une cible de production. Le but est de trouver les hypothèses des analyseurs dans la passerelle et le récepteur avant que l'agent n'atteigne un environnement réel doté d'identifiants.
Enfin, examinez chaque endroit où des données structurées redeviennent du texte. Cela comprend les visionneuses de journaux avec des commandes cliquables, les extraits shell générés, la configuration CI, les rapports d'erreur, les notifications de discussion et les enveloppes distantes. Les failles d'injection réapparaissent souvent lorsqu'une requête structurée sûre devient une chaîne pratique dans un composant ultérieur.
La première action à corriger est généralement la plus flexible. Remplacez run_command(command) par une opération qui possède un nom, un petit schéma de requête, un exécutable fixe et une suite de tests remplie d'entrées que personne ne saisirait lors d'une démonstration normale. Ce changement retire toute une catégorie de comportements du modèle de menace et vous laisse des règles que vous pouvez réellement inspecter.
FAQ
Les guillemets autour d'une commande suffisent-ils à empêcher l'injection de commandes ?
Les guillemets ne font que modifier la façon dont un shell interprète une valeur. Ils ne déterminent pas si cette valeur désigne un fichier, une branche, un hôte ou une ressource d'API autorisé. Considérez les guillemets comme une protection du parsing et la validation comme une définition des permissions. Il faut les deux.
Faut-il autoriser un agent IA à utiliser sh -c ?
En général, non. Utilisez une API de processus qui accepte un exécutable et un tableau d'arguments, avec l'exécution par shell désactivée. Vous supprimez ainsi la grammaire du shell, mais vous devez toujours valider les arguments selon la sémantique du programme appelé.
Un chemin apparemment sûr peut-il quand même causer des dégâts ?
Un chemin comme ../../private/config ne contient aucun métacaractère du shell, mais peut tout de même sortir du répertoire prévu. Résolvez le chemin par rapport à une racine approuvée, vérifiez qu'il reste dans cette racine après résolution et tenez compte des liens symboliques avant de l'ouvrir ou de l'utiliser.
Les tableaux d'arguments sont-ils sûrs quand la commande s'exécute via SSH ?
Non. Un argument séparé reste une donnée pour le processus local, mais SSH transmet généralement une chaîne de commande qu'un shell distant doit interpréter. Envoyez une commande distante constante et transmettez les données structurées via l'entrée standard, puis validez-les de nouveau à distance.
Comment valider une branche Git fournie par un agent ?
Refusez les noms qui ne correspondent pas à la grammaire réellement prise en charge par votre workflow, puis convertissez le nom accepté dans un espace de noms fixe comme refs/heads/ suivi du nom. N'acceptez pas des révisions arbitraires, des expressions de références ou des options de commande simplement parce que Git sait les interpréter.
Pourquoi l'injection indirecte de prompt est-elle importante pour les commandes shell ?
L'injection de prompt n'a pas besoin d'avoir l'air malveillante. Un fichier du dépôt, une description d'issue, un journal de compilation ou une réponse d'API peut demander à l'agent de transmettre une valeur dangereuse à un outil. La frontière de l'outil doit appliquer ses propres règles, même lorsque l'agent suit fidèlement ses instructions.
Les boîtes de dialogue d'approbation empêchent-elles les commandes dangereuses d'un agent ?
Une approbation indique qu'un processus peut effectuer un appel. Elle ne transforme pas un modèle de commande dangereux en modèle sûr et ne prouve pas que l'argument reste dans le périmètre autorisé. Concevez l'action de manière sûre avant de demander à quelqu'un de l'approuver.
Que doit enregistrer un journal d'audit pour une commande d'agent ?
Un journal utile contient l'exécutable, chaque argument dans un champ séparé, l'identité de l'appelant, le contexte de la cible, le résultat ainsi qu'une empreinte ou une copie conservée de manière sécurisée des données structurées. Une simple ligne de commande reconstruite fait disparaître les limites dont vous aurez besoin pendant une enquête.
Dois-je bloquer les caractères spéciaux ou utiliser une liste blanche ?
Refuser tout caractère inhabituel n'est sûr que si votre produit peut fonctionner avec cette grammaire restrictive. Préférez une liste blanche correspondant au domaine réel, par exemple des noms de déploiement simples ou des branches de dépôt, et documentez les formes prises en charge. Ne prétendez pas qu'une liste noire générique comprend tous les analyseurs impliqués.
Comment tester une passerelle d'actions d'agent contre l'injection de commandes ?
Commencez par un outil qui accepte une opération limitée, une requête structurée et aucun champ de commande shell. Testez-le avec des séparateurs, des substitutions, des caractères d'option en début de valeur, des chemins de traversée, des retours à la ligne et des valeurs syntaxiquement valides mais hors périmètre. Si un cas atteint une action imprévue, l'interface est trop large.