execução segura de comandos com Python: subprocesso.Popen

A segurança é importante para mim enquanto desenvolvo o picockpit-cliente.

O seguinte aplica-se aos sistemas Linux (mas provavelmente é aplicável a todos os sistemas como o Unix, incluindo o MacOS)

Python permite executar comandos externos usando o módulo de subprocesso.

subprocesso de importação

Na próxima versão do PiCockpit, os usuários poderão criar seus próprios botões (simplesmente editando um arquivo JSON no Pi) a fim de executar comandos.

PiCockpit será enviado com três botões por padrão:

imagem

Todos estes comandos requerem privilégios de root. picockpit-cliente, por padrão, roda com privilégios de root no seu sistema.

Mas outros comandos que você quer executar, devem ser executados como usuários menos privilegiados (o padrão é para o usuário "pi"). Na verdade, alguns comandos, como o Chromium Browser irá recusar para correr como raiz.

Em suma, precisamos de uma solução para executar estes processos utilizando um utilizador diferente.

Isto é possível, graças ao preexec_fn parâmetro para popen.

Aqui está o meu código:

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-\u0008u000b-\u000c\u000e-\u001f\ufffe-\uff])' + {\u0008
                          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))
         retornar re.sub(RE_XML_ILLEGAL, "", input_string)

    def demote(user, uid, gid):
         def demote_function():
             print("começando")
             print ('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
             imprimir (os.getgroups())
             # initgroups devem ser executados antes de perdermos o privilégio de o definir!
             os.initgroups(usuário, gid)
             os.setgid(gid)
             # isto deve ser executado por último
             os.setuid(uid)
             print("finished demotion")
             print('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
             imprimir (os.getgroups())
         return demote_function

    def run_cmd(cmd, cmd_def):
         return_code = 0
         return_error = ""
         return_result = ""
         timeout = Nenhum
         usuário = "pi
         se "timeout" em cmd_def:
             if isinstance(cmd_def["timeout"], int):
                 se cmd_def["timeout"] > 0:
                     timeout = cmd_def["timeout"]
         se "usuário" em cmd_def:
             if isinstance(cmd_def["user"], str):
                 usuário = cmd_def["usuário"]
         tente:
             print("picontrol :: configuração dos direitos do utilizador")
             uid = pwd.getpwnam(user).pw_uid
             gid = pwd.getpwnam(user).pw_gid
             print("picontrol :: utilizador solicitado " + utilizador + " uid " + str(uid) + " gid " + str(gid))
             print("picontrol :: comando de arranque ... ")
             imprimir(cmd)
             proc = subprocesso.Popen(
                 cmd,
                 stdout=subprocess.PIPE,
                 stderr=subprocess.PIPE,
                 preexec_fn=demote(user, uid, gid)
             )
             # TODO: lidar com timeout de forma diferente
                 # timeout=timeout)               
             print("picontrol :: APÓS o subprocesso.Popen")
         excepto FileNotFoundError:
             return_code = errno.ENOENT
             return_error = "Comando não encontrado no seu sistema " + " ".join(cmd)
         # TODO: isto alguma vez será realmente chamado?
         exceto subprocesso.TimeoutExpirado:
             return_code = errno.ETIME
             return_error = "Timout de comando expirado (" + str(cmd_def["timeout"]) + " seg)"
         exceto Exceção como e:
             se hasattr(e, 'errno'):
                 return_code = e.errno
             senão..:
                 return_code = 1
             se hasattr(e, 'mensagem'):
                 return_error = str(e.message)
             senão..:
                 return_error = str(e)
         senão..:
             O processo # pode ser executado normalmente, sem exceções na inicialização
             process_running = Verdadeiro
             durante a execução do_processo:
                 se proc.poll() não for Nenhuma:
                     process_running = Falso
                 se self.stop_flag.is_set():
                     print("stop flag received in process_running, setting process_running to false")
                     process_running = Falso
                     # e também fazer passos para a terminação activa depois disso.
                 # meio segundo de resolução
                 tempo.de sono(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"))

        retorno (código_retorno, erro_retorno, retorno_resultado)

Este código é um trabalho em andamento, portanto partes dele podem não funcionar. As partes que eu quero discutir neste post do blog, no entanto, funcionam.

Usuário sob Linux

Um nome de usuário, sob o Linux, será atribuído a um user id, um grupo de usuários, e o usuário será um membro de outros grupos.

/etc/passwd contém os usuários definidos no sistema:

imagem

As entradas podem ser descodificadas da seguinte forma:

usuário : senha : user id (uid) : group id (gid) : nome completo do usuário (GECOS) : diretório home do usuário : shell de login

sidenote interessante: o armazenamento da senha em /etc/passwd (de forma criptografada) é opcional (caso a senha não seja armazenada, um x é armazenado aqui); Como este arquivo é legível mundialmente, muitos sistemas optam por usar o /etc/shadow legível pela raiz em seu lugar:

imagem

que de facto contém a senha.

Para o picockpit, obtemos o nome de utilizador do ficheiro de definições JSON, e queremos configurar tudo o resto de acordo. Por isso, começamos com:

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

(Não se esqueça de dívida pública de importação).

O módulo pwd permite-nos aceder ao ficheiro /etc/passwd usando o Python. getpwnam procura na linha correspondente pelo nome do utilizador, no nosso caso "pi", ou "root" (ou o que quiser, o que estiver configurado no ficheiro JSON).

Isto nos ajudará a obter o id do usuário (uid) e o id do seu grupo (gid).

A seguir chamamos de subprocesso. Popen:

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

As stdout e stderr são PIPEd, para que possamos capturá-las mais tarde.

o preexec_fn está definido para demote(user, uid, gid).

Importante!

Isto é uma parte importante: preexec_fn espera uma função que vai executar no contexto do novo processo que está a iniciar (por isso o processo vai-se desmobilizar!). Esta função será executada antes do código real, assim podemos ter certeza de que o código será executado no contexto em que queremos que ele execute.

A forma como passamos aqui, parece que a função está a ser chamada.

Mas olha para a definição de demote:

def demote(user, uid, gid):
     def demote_function():
        print("começando")
         print ('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
         imprimir (os.getgroups())
         # initgroups devem ser executados antes de perdermos o privilégio de o definir!
         os.initgroups(usuário, gid)
         os.setgid(gid)
         # isto deve ser executado por último
         os.setuid(uid)
         print("finished demotion")
         print('uid, gid = %d, %d' % (os.getuid(), os.getgid()))
         imprimir (os.getgroups())
     return demote_function

Esta função envolve outra função dentro de si mesma, a qual retorna. Usando a sintaxe que é especificada acima, nós realmente devolver uma função mas são capazes de lhe passar parâmetros, também.

Como ter o teu bolo e comê-lo. Bom Sorria

Aqui está uma captura de tela, para o caso de o WordPress mexer com a sintaxe acima:

imagem

Muito bem, agora vou explicar o código nesta função de despromover:

os.initgroups:

configuramos os grupos dos quais o processo deve ser membro (sim, temos que fazer isso - caso contrário o processo será apenas um membro do grupo pi padrão) - este comando espera que o usuário nome

os.setgid:

aqui montamos a identificação do grupo para o novo processo.

Há também uma função os.setegid() que é descrito como "Definir o ID do grupo efetivo do processo atual". 

Qual é a diferença?

setegid define o gid de uma forma temporária, para que o processo possa voltar ao gid original mais tarde. Isto não é o que nós queremos, uma vez que o processo que estamos chamando poderia atualizar seus direitos de volta.

os.setuid(uid):

Da mesma forma, há um os.seteuid(euid) o que permitiria que o processo executado "atualizasse de volta". Portanto, não vamos usar isso.

A ordem destes comandos é importante! Se definirmos a identificação do usuário primeiro, não teremos privilégios suficientes para configurar os grupos, por exemplo. Portanto, defina o user id por último.

Sidenote: preexec_fn poderia ser usado para configurar variáveis de ambiente e outras coisas, também, para permitir mais flexibilidade. Possivelmente em uma futura atualização picockpit-cliente, vamos ver.

Sidenote 2: o preexec_fn escreve para o novo fluxo de saída do processo (lembre-se, ele é pré-executado em seu contexto):

É por isso que a saída de depuração é enviada para o front-end do picockpit:

imagem

Ref

Links de interesse adicionais:

Bónus: Crómio

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

imagem

permitirá que você execute o navegador Chromium a partir da linha de comando (na shell). -display=:0 define o display X a ser usado para o Chromium sair, assim você não precisa configurar a variável de ambiente apropriada.

Peter.sh dá toneladas de chaves de linha de comando, que você pode usar para configurar o Chromium a seu gosto (por exemplo, modo quiosque, etc).

Note que você PRECISA executar um servidor X, se você quiser exibir o Chromium. No Raspberry Pi, a maneira mais fácil de fazer é rodar o Raspberry Pi Desktop.

Procurando consultoria e desenvolvimento profissional para a plataforma Raspberry Pi?

Nós oferecemos serviços de consultoria e desenvolvimento no caso de estar à procura de alguém para montar uma aplicação Kiosk segura à base de Crómio para si. Entre em contato hoje.