esecuzione sicura dei comandi con Python: subprocess.Popen

La sicurezza è importante per me durante lo sviluppo del client picockpit.

Quanto segue si applica ai sistemi Linux (ma probabilmente è applicabile a tutti i sistemi simili a Unix, incluso macOS)

Python permette di eseguire comandi esterni usando il modulo subprocesso.

importa subprocesso

Nella prossima versione di PiCockpit, gli utenti saranno in grado di creare i propri pulsanti (semplicemente modificando un file JSON sul Pi) per eseguire i comandi.

PiCockpit verrà spedito con tre pulsanti di default:

immagine

Questi comandi richiedono tutti i privilegi di root. picockpit-client, per default, viene eseguito con i privilegi di root sul tuo sistema.

Ma altri comandi che volete eseguire, dovrebbero essere eseguiti come utenti con meno privilegi (noi abbiamo come default l'utente "pi"). Infatti, alcuni comandi, come il Browser Chromium rifiuti da eseguire come root.

In breve, abbiamo bisogno di una soluzione per eseguire questi processi usando un utente diverso.

Questo è possibile grazie al preexec_fn per popen.

Ecco il mio codice:

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])' + \
                          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(utente, uid, gid):
         def demote_function():
             stampa("inizio")
             stampa ('uid, gid = %d, %d' % (os.getuid(), os.getgid())
             stampa (os.getgroups())
             # initgroups deve essere eseguito prima di perdere il privilegio di impostarlo!
             os.initgroups(user, gid)
             os.setgid(gid)
             # questo deve essere eseguito per ultimo
             os.setuid(uid)
             stampa("retrocessione finita")
             print('uid, gid = %d, %d' % (os.getuid(), os.getgid())
             stampa (os.getgroups())
         ritorno funzione_demoteca

    def run_cmd(cmd, cmd_def):
         return_code = 0
         return_error = ""
         return_result = ""
         timeout = Nessuno
         utente = "pi"
         se "timeout" in cmd_def:
             if isinstance(cmd_def["timeout"], int):
                 se cmd_def["timeout"] > 0:
                     timeout = cmd_def["timeout"]
         se "utente" in cmd_def:
             if isinstance(cmd_def["user"], str):
                 utente = cmd_def["utente"]
         provare:
             print("picontrol :: impostazione dei diritti utente")
             uid = pwd.getpwnam(user).pw_uid
             gid = pwd.getpwnam(user).pw_gid
             print("picontrol :: utente richiesto " + utente + " uid " + str(uid) + " gid " + str(gid))
             print("picontrol :: avvio del comando ... ")
             stampa(cmd)
             proc = subprocess.Popen(
                 cmd,
                 stdout=subprocesso.PIPE,
                 stderr=subprocesso.PIPE,
                 preexec_fn=demote(user, uid, gid)
             )
             # TODO: gestire il timeout in modo diverso
                 # timeout=timeout)               
             print("picontrol :: AFTER subprocess.Popen")
         eccetto FileNotFoundError:
             return_code = errno.ENOENT
             return_error = "Comando non trovato sul tuo sistema " + " ".join(cmd)
         # TODO: sarà mai chiamato?
         eccetto subprocess.TimeoutExpired:
             return_code = errno.ETIME
             return_error = "Timeout del comando scaduto (" + str(cmd_def["timeout"]) + " sec)"
         eccetto Exception come e:
             se hasattr(e, 'errno'):
                 return_code = e.errno
             altro:
                 return_code = 1
             se hasattr(e, 'messaggio'):
                 return_error = str(e.message)
             altro:
                 return_error = str(e)
         altro:
             Il processo # può essere eseguito normalmente, nessuna eccezione all'avvio
             process_running = True
             mentre process_running:
                 se proc.poll() non è None:
                     process_running = False
                 se self.stop_flag.is_set():
                     print("stop flag ricevuto in process_running, impostando process_running su false")
                     process_running = False
                     # e fare anche dei passi per la terminazione attiva dopo di che.
                 # mezzo secondo di risoluzione
                 time.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)

Questo codice è un lavoro in corso, quindi alcune parti potrebbero non funzionare. Le parti che voglio discutere in questo post, tuttavia funzionano.

Utente sotto Linux

Ad un nome utente, sotto Linux, verrà assegnato un id utente, un gruppo utente e l'utente sarà un membro di altri gruppi.

/etc/passwd contiene gli utenti definiti sul sistema:

immagine

Le voci possono essere decodificate come segue:

utente: password: id utente (uid): id gruppo (gid): nome completo dell'utente (GECOS): home directory dell'utente: login shell

Nota a margine interessante: memorizzare la password in /etc/passwd (in forma criptata) è opzionale (nel caso in cui la password non sia memorizzata, un x è memorizzato qui); poiché questo file è leggibile da tutto il mondo, molti sistemi scelgono invece di usare il file /etc/shadow leggibile da root:

immagine

che contiene effettivamente la password.

Per picockpit, otteniamo il nome utente dal file delle impostazioni JSON, e vogliamo impostare tutto il resto di conseguenza. Quindi iniziamo con:

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

(Assicuratevi di importare pwd).

Il modulo pwd ci permette di accedere al file /etc/passwd usando Python. getpwnam cerca la linea corrispondente per nome dell'utente, nel nostro caso "pi", o "root" (o quello che volete, qualunque cosa sia impostata nel file JSON).

Questo ci aiuterà a ottenere l'id dell'utente (uid) e il suo id di gruppo (gid).

Poi chiamiamo subprocess.Popen:

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

Lo stdout e lo stderr sono PIPEd, così possiamo catturarli in seguito.

il preexec_fn è impostato su demote(user, uid, gid).

Importante!

Questo è un pezzo importante: preexec_fn si aspetta una funzione che eseguirà nel contesto del nuovo processo che sta avviando (quindi il processo si autodeclasserà!). Questa funzione verrà eseguita prima del codice vero e proprio, così possiamo essere sicuri che il codice verrà eseguito nel contesto in cui vogliamo che venga eseguito.

Il modo in cui passiamo la retrocessione qui, sembra che la funzione venga chiamata.

Ma guardate la definizione di degradare:

def demote(utente, uid, gid):
     def demote_function():
        stampa("inizio")
         stampa ('uid, gid = %d, %d' % (os.getuid(), os.getgid())
         stampa (os.getgroups())
         # initgroups deve essere eseguito prima di perdere il privilegio di impostarlo!
         os.initgroups(user, gid)
         os.setgid(gid)
         # questo deve essere eseguito per ultimo
         os.setuid(uid)
         stampa("retrocessione finita")
         print('uid, gid = %d, %d' % (os.getuid(), os.getgid())
         stampa (os.getgroups())
     ritorno funzione_demoteca

Questa funzione avvolge dentro di sé un'altra funzione, che restituisce. Usando la sintassi che è specificata sopra, in realtà restituire una funzione ma sono anche in grado di passargli dei parametri.

Un po' come avere la torta e mangiarla. Bello Sorriso

Ecco uno screenshot, nel caso in cui WordPress si incasini con la sintassi di cui sopra:

immagine

OK, ora vi spiegherò il codice di questa funzione di retrocessione:

os.initgroups:

impostiamo i gruppi di cui il processo dovrebbe essere membro (sì, dobbiamo farlo - altrimenti il processo sarà solo membro del gruppo pi di default) - questo comando si aspetta che l'utente nome

os.setgid:

qui impostiamo l'id del gruppo per il nuovo processo.

C'è anche una funzione os.setegid() che è descritto come "Imposta l'id di gruppo effettivo del processo corrente". 

Qual è la differenza?

setegid imposta il gid in modo temporaneo, in modo che il processo possa tornare al gid originale in seguito. Questo non è quello che vogliamo, dato che il processo che stiamo chiamando potrebbe aggiornare di nuovo i suoi diritti.

os.setuid(uid):

Allo stesso modo, c'è un os.seteuid(euid) che permetterebbe al processo eseguito di "tornare indietro". Quindi non la usiamo.

L'ordine di questi comandi è importante! Se impostassimo prima l'id utente, non avremmo privilegi sufficienti per impostare i gruppi, per esempio. Quindi impostate l'id utente per ultimo.

Nota a margine: preexec_fn potrebbe essere usato anche per impostare variabili d'ambiente e altre cose, per consentire una maggiore flessibilità. Possibilmente in un futuro aggiornamento di picockpit-client, vediamo.

Nota a margine 2: il preexec_fn scrive sul flusso di uscita del nuovo processo (ricordate, è pre-eseguito nel suo contesto):

Questo è il motivo per cui l'output di debug viene inviato al frontend di picockpit:

immagine

Rif

Altri link di interesse:

Bonus: Cromo

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

immagine

vi permetterà di eseguire il browser Chromium dalla riga di comando (nella shell). -display=:0 imposta il display X da utilizzare per l'output di Chromium, quindi non è necessario impostare la variabile d'ambiente appropriata.

Peter.sh fornisce tonnellate di interruttori a riga di comando, che è possibile utilizzare per configurare Chromium a proprio piacimento (ad esempio la modalità kiosk, ecc).

Si noti che è NECESSARIO eseguire un server X, se si vuole visualizzare Chromium. Sul Raspberry Pi, il modo più semplice per farlo è eseguire il Raspberry Pi Desktop.

Stai cercando consulenza e sviluppo professionale per la piattaforma Raspberry Pi?

Offriamo servizi di consulenza e sviluppo nel caso in cui stiate cercando qualcuno che imposti per voi un'applicazione Kiosk sicura basata su Chromium. Mettiti in contatto oggi stesso.