Não chame o shell, passe os argumentos como lista e valide a entrada por lista de permissão.
Não existe um filtro mágico de caracteres; a defesa é parar de montar texto para o shell. Em camadas, da mais importante para a de reforço:
Não chame o shell. Em vez de os.system("ping -c1 " + host), use a chamada que recebe uma lista de argumentos: subprocess.run(["ping", "-c1", host]), sem shell=True. Sem shell, ;, |, $() e ${IFS} são só caracteres do argumento, nunca comandos.
Prefira a biblioteca ao programa externo. Muitas vezes nem precisa chamar um programa: há uma função que faz o mesmo dentro da linguagem.
Ponha -- antes do valor do usuário quando o programa aceita opções, para um valor que começa com - não virar opção (fecha a injeção de argumento).
Valide por lista de permissão. Aceite só o formato esperado (um IP, um nome de domínio, um nome de arquivo de imagem). Uma lista de permissão diz o que PODE; uma lista de bloqueio tenta adivinhar o que não pode, e sempre esquece um caractere.
Ler o patch é ver essas camadas aparecerem: a string virar lista, o shell=True sumir, o -- entrar, a validação no começo. O mesmo em Node: trocar exec("convert " + name) por execFile("convert", ["-resize", "100x100", name]).
Dois caminhos: a string entregue ao shell deixa o metacaractere virar comando; a lista de argumentos com validação faz o mesmo texto ser só um argumento.