exécution sécurisée des commandes avec Python : subprocess.Popen

La sécurité est importante pour moi lors du développement du picockpit-client.

Ce qui suit s'applique aux systèmes Linux (mais s'applique probablement à tous les systèmes de type Unix, y compris macOS)

Python permet d'exécuter des commandes externes à l'aide du module subprocess.

Importation de sous-processus

Dans la prochaine version de PiCockpit, les utilisateurs pourront créer leurs propres boutons (en modifiant simplement un fichier JSON sur le Pi) afin d'exécuter des commandes.

PiCockpit sera livré avec trois boutons par défaut :

image

Ces commandes requièrent toutes les privilèges de root. picockpit-client, par défaut, s'exécute avec les privilèges de root sur votre système.

Mais d'autres commandes que vous voulez exécuter, devraient être exécutées par des utilisateurs moins privilégiés (nous utilisons par défaut l'utilisateur "pi"). En fait, certaines commandes, comme le navigateur Chromium, vont refuser pour être exécuté en tant que root.

En bref, nous avons besoin d'une solution pour exécuter ces processus en utilisant un utilisateur différent.

Cela est possible, grâce à la preexec_fn paramètre pour popen.

Voici mon code :

def thread_run(self) :
     def sanitize(input_string) :
         #https://maxharp3r.wordpress.com/2008/05/15/pythons-minidom-xml-and-illegal-unicode-characters/
         RE_XML_ILLEGAL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \o
                          u'|' + \
                          u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])’ % \
                          (chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff),
                           chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff),
                           chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff))
         return re.sub(RE_XML_ILLEGAL, "", input_string)

    def demote(utilisateur, uid, gid) :
         def demote_function() :
             print("starting")
             print ('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
             print (os.getgroups())
             # initgroups doit être exécuté avant que nous perdions le privilège de le définir !
             os.initgroups(user, gid)
             os.setgid(gid)
             # cela doit être exécuté en dernier
             os.setuid(uid)
             print("rétrogradation terminée")
             print('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
             print (os.getgroups())
         retour de la fonction démote

    def run_cmd(cmd, cmd_def) :
         return_code = 0
         return_error = ""
         return_result = ""
         timeout = Aucun
         utilisateur = "pi"
         si "timeout" dans cmd_def :
             si isinstance(cmd_def["timeout"], int) :
                 si cmd_def["timeout"] > 0 :
                     timeout = cmd_def["timeout"]
         si "user" dans cmd_def :
             si isinstance(cmd_def["user"], str) :
                 utilisateur = cmd_def["utilisateur"]
         essayez :
             print("picontrol : : configuration des droits de l'utilisateur")
             uid = pwd.getpwnam(user).pw_uid
             gid = pwd.getpwnam(user).pw_gid
             print("picontrol : : requested user " + user + " uid " + str(uid) + " gid " + str(gid))
             print("picontrol : : starting command ... ")
             print(cmd)
             proc = subprocess.Popen(
                 cmd,
                 stdout=sous-processus.PIPE,
                 stderr=sous-processus.PIPE,
                 preexec_fn=demote(user, uid, gid)
             )
             # TODO : gérer le délai d'attente différemment
                 # timeout=timeout)               
             print("picontrol : : AFTER subprocess.Popen")
         sauf FileNotFoundError :
             return_code = errno.ENOENT
             return_error = "Commande non trouvée sur votre système " + " ".join(cmd)
         # TODO : est-ce que cela sera un jour appelé ?
         sauf subprocess.TimeoutExpired :
             return_code = errno.ETIME
             return_error = "Command timeout expired (" + str(cmd_def["timeout"]) + " sec)"
         sauf Exception comme e :
             si hasattr(e, 'errno') :
                 return_code = e.errno
             autre :
                 return_code = 1
             si hasattr(e, 'message') :
                 return_error = str(e.message)
             autre :
                 return_error = str(e)
         autre :
             Le processus # peut s'exécuter normalement, sans exception au démarrage.
             process_running = True
             while process_running :
                 si proc.poll() n'est pas None :
                     process_running = Faux
                 si self.stop_flag.is_set() :
                     print("drapeau d'arrêt reçu dans process_running, mettant process_running à false")
                     process_running = Faux
                     # et faire aussi les étapes pour la terminaison active après cela.
                 # une demi-seconde de résolution
                 temps.sleep(0.5)
             return_code = proc.returncode
             return_error = sanitize(proc.stderr.read().decode('UTF-8').rstrip("\r\n"))
             return_result = sanitize(proc.stdout.read().decode('UTF-8').rstrip("\r\n"))

        return (return_code, return_error, return_result)

Ce code est un travail en cours, donc certaines parties peuvent ne pas fonctionner. Les parties dont je veux parler dans cet article de blog, cependant, fonctionnent.

Utilisateur sous Linux

Un nom d'utilisateur, sous Linux, se verra attribuer un identifiant, un groupe d'utilisateurs, et l'utilisateur sera un membre d'autres groupes.

/etc/passwd contient les utilisateurs définis sur le système :

image

Les entrées peuvent être décodées comme suit :

utilisateur : mot de passe : identifiant de l'utilisateur (uid) : identifiant du groupe (gid) : nom complet de l'utilisateur (GECOS) : répertoire d'origine de l'utilisateur : shell de connexion

Remarque intéressante : le stockage du mot de passe dans /etc/passwd (sous une forme cryptée) est facultatif (dans le cas où le mot de passe n'est pas stocké, une x est stocké ici) ; Comme ce fichier est lisible par le monde entier, de nombreux systèmes choisissent d'utiliser le fichier /etc/shadow lisible par les racines à la place :

image

qui contient effectivement le mot de passe.

Pour picockpit, nous obtenons le nom d'utilisateur à partir du fichier de paramètres JSON, et nous voulons configurer tout le reste en conséquence. Donc nous commençons avec :

uid = pwd.getpwnam(user).pw_uid
gid = pwd.getpwnam(user).pw_gid

(Veillez à Importer pwd).

Le module pwd nous permet d'accéder au fichier /etc/passwd en utilisant Python. getpwnam recherche la ligne correspondante par le nom de l'utilisateur, dans notre cas "pi", ou "root" (ou ce que vous voulez, ce qui est défini dans le fichier JSON).

Cela nous permettra d'obtenir l'identifiant de l'utilisateur (uid) et son identifiant de groupe (gid).

Ensuite, nous appelons subprocess.Popen :

proc = subprocess.Popen(
     cmd,
     stdout=sous-processus.PIPE,
     stderr=sous-processus.PIPE,
     preexec_fn=demote(user, uid, gid)
)

Les stdout et stderr sont PIPEd, afin que nous puissions les capturer plus tard.

le preexec_fn est défini comme demote(user, uid, gid).

Important !

C'est un élément important : preexec_fn attend une fonction qu'il exécutera dans le contexte du nouveau processus qu'il démarre (pour que le processus se rétrograde lui-même !). Cette fonction sera exécutée avant le code réel, ainsi nous pouvons être sûrs que le code s'exécutera dans le contexte que nous voulons qu'il exécute.

La façon dont nous passons la rétrogradation ici, on dirait que la fonction est appelée.

Mais regardez la définition de "rétrograder" :

def demote(utilisateur, uid, gid) :
     def demote_function() :
        print("starting")
         print ('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
         print (os.getgroups())
         # initgroups doit être exécuté avant que nous perdions le privilège de le définir !
         os.initgroups(user, gid)
         os.setgid(gid)
         # cela doit être exécuté en dernier
         os.setuid(uid)
         print("rétrogradation terminée")
         print('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
         print (os.getgroups())
     retour de la fonction démote

Cette fonction enveloppe une autre fonction en son sein, qu'elle renvoie. En utilisant la syntaxe spécifiée ci-dessus, nous avons en fait retourner une fonction mais peuvent également lui passer des paramètres.

C'est un peu comme avoir son gâteau et le manger. Nice Sourire

Voici une capture d'écran, au cas où WordPress s'embrouillerait avec la syntaxe ci-dessus :

image

OK, maintenant je vais expliquer le code de cette fonction de rétrogradation :

os.initgroups :

nous définissons les groupes dont le processus doit être membre (oui, nous devons le faire - sinon le processus sera seulement membre du groupe pi par défaut) - cette commande attend l'utilisateur nom

os.setgid :

ici nous configurons l'id de groupe pour le nouveau processus.

Il existe également une fonction os.setegid() qui est décrit comme "Définir l'identifiant de groupe effectif du processus actuel". 

Quelle est la différence ?

setegid définit le gid de manière temporaire, de sorte que le processus puisse revenir au gid original plus tard. Ce n'est pas ce que nous voulons, puisque le processus que nous appelons pourrait remettre ses droits à jour.

os.setuid(uid) :

De même, il existe un os.seteuid(euid) qui permettrait au processus exécuté d'effectuer une "remise à niveau". Nous ne l'utilisons donc pas.

L'ordre de ces commandements est important ! Si nous définissions l'identifiant de l'utilisateur en premier, nous n'aurions pas les privilèges suffisants pour configurer les groupes, par exemple. Il faut donc définir l'identifiant de l'utilisateur en dernier.

Note complémentaire : preexec_fn pourrait être utilisé pour configurer des variables d'environnement et d'autres choses, aussi, pour permettre plus de flexibilité. Peut-être dans une future mise à jour de picockpit-client, voyons voir.

Remarque 2 : le preexec_fn écrit dans le flux de sortie du nouveau processus (rappelez-vous, il est pré-exécuté dans son contexte) :

C'est pourquoi la sortie de débogage est envoyée au frontal picockpit :

image

Réf.

Autres liens d'intérêt :

Bonus : Chrome

chromium-browser -display=:0 https://www.picockpit.com

image

vous permettra d'exécuter le navigateur Chromium à partir de la ligne de commande (dans le shell). -display=:0 définit l'affichage X à utiliser pour la sortie de Chromium, vous n'avez donc pas besoin de configurer la variable d'environnement appropriée.

Peter.sh donne des tonnes de commutateurs de ligne de commande, que vous pouvez utiliser pour configurer Chromium à votre goût (par exemple, le mode kiosque, etc).

Notez que vous DEVEZ exécuter un serveur X, si vous voulez afficher Chromium. Sur le Raspberry Pi, la façon la plus simple de procéder est d'exécuter le Raspberry Pi Desktop.

Vous recherchez des services professionnels de conseil et de développement pour la plateforme Raspberry Pi ?

Nous offrons des services de conseil et de développement si vous cherchez quelqu'un pour mettre en place pour vous une application kiosque sécurisée basée sur Chromium. Prenez contact avec nous dès aujourd'hui.