ReuseLessSoftware - Reutilizar 'menos' software
(wiki.alopex.li)- Ataques à cadeia de suprimentos se tornaram um problema maior porque o custo de distribuir software caiu muito e a automação de build e deploy passou a ser amplamente usada
- Na década de 1970 houve uma crise do software, quando era difícil criar software reutilizável, mas hoje repositórios e gerenciadores de pacotes buscam e compilam código só com nome e versão
- Atualizações automáticas de dependências fazem mudanças maliciosas se espalharem rapidamente via CI, e um bom ataque à cadeia de suprimentos se propaga na velocidade em que os runners de CI executam
- Vendorizar todas as dependências junto no repositório do projeto aumenta o repositório, mas bloqueia mudanças automáticas e torna mais visíveis o tamanho e o custo das dependências
- Não é uma solução para todo tipo de software, mas muitos softwares pequenos podem se beneficiar ao reduzir para 2 ou 3 dependências aquelas que podem mudar de repente do lado de fora
Problema
- Ataques à cadeia de suprimentos se tornaram um problema cada vez maior não porque a natureza do software ou da manutenção tenha mudado, mas porque o modelo de custo para compartilhar e distribuir software ficou extremamente baixo
- O custo de distribuição ficou tão baixo que passamos a usar muita automação mesmo com desperdícios, e a automação em si é útil
- A cada poucos meses surge um novo ataque à cadeia de suprimentos que acaba comprometendo uma grande parte do código do mundo
Como chegamos até aqui
- No fim dos anos 1960 e começo dos anos 1970, as pessoas não sabiam bem como criar software reutilizável, e isso ficou conhecido como crise do software
- A demanda por software crescia exponencialmente, mas a capacidade de produzir novo software com a complexidade exigida crescia mais devagar
- Esse período levou a pesquisas sobre modularidade, programação estruturada e afins, e quase todo sistema de módulos de linguagens de programação criado depois de 1990 pode traçar sua linhagem até o Modula-2
- Nas décadas de 1990 e 2000, a internet trouxe soluções mais fortes, o build e a distribuição de software ficaram baratos, e boa parte do software que as pessoas realmente queriam usar era open source
- Com base em CPAN, CTAN e distribuições Linux, surgiram muitos repositórios de pacotes e gerenciadores de pacotes, e essas ferramentas localizam, baixam e compilam software usando apenas arquivos de manifesto, nomes e, em geral, números de versão arbitrários
-
Da integração manual às dependências automáticas
- Antigamente, uma boa forma de montar sistemas de software complexos era juntar manualmente e com cuidado partes que funcionavam, e distribuições Linux basicamente fazem isso
- Em 2003, compilar o SDL com todos os recursos era doloroso a ponto de levar dias, e não é preciso sentir saudade daquela época
- Quando uma distribuição Linux oferece um ambiente-base conhecido, muito software customizado pode funcionar dentro do seu próprio mundo sem precisar se preocupar muito com outras partes do sistema
- Quando se comunica com outros softwares, isso costuma acontecer por arquivos ou sockets de rede usando protocolos bem conhecidos
- Passou a existir muito software bom criado do zero em Rust ou Go, ou distribuído em contêineres Docker, e esse software quase não interage com bibliotecas do sistema
- Em vez de se adequar ao conjunto de software fornecido pela distribuição do sistema operacional, tornou-se comum o sistema de build buscar diretamente as bibliotecas necessárias
-
A crise na direção oposta
- Hoje existe, em sentido oposto ao dos anos 1970, uma crise em que as pessoas reutilizam software demais e os programas ficam piores por isso
- Distribuir software continua sendo muito barato, mas usar software ainda tem custo
- Durante muito tempo, o maior custo foi a complexidade de compilar o software e fazê-lo rodar num computador, mas esse problema em grande parte desapareceu com a automação
- Agora compilamos, distribuímos e usamos muito mais software, e esse custo aparece na forma de inferno de dependências, inchaço, builds longos e desaparecimento de pacotes ou de gerenciadores de pacotes
- O maior problema de todos são os ataques à cadeia de suprimentos
-
A estrutura de propagação dos ataques à cadeia de suprimentos
- Ataques à cadeia de suprimentos são um problema tão antigo quanto o software open source
- No passado, a tentativa de patch malicioso no kernel Linux para inserir
uid = 0em vez deuid == 0foi o primeiro patch malicioso de kernel observado na prática, e se enquadra como tentativa de ataque à cadeia de suprimentos - O motivo de os ataques à cadeia de suprimentos terem se tornado maiores e mais problemáticos na última década é a automação dos sistemas de build para buscar código-fonte e distribuí-lo
- Sistemas de CI normalmente rodam em toda mudança de código ou em mudanças grandes, e essas mudanças passam automaticamente a ficar disponíveis para todos que dependem daquele código
- Os sistemas de CI de quem depende também puxam a mudança e passam a incluir o novo código malicioso, e um bom ataque à cadeia de suprimentos se espalha como fogo em mata seca na velocidade em que os runners de CI executam
- Existem formas de desacelerar ataques à cadeia de suprimentos, como cooldown de dependências, mas isso gera discussões sobre políticas e responsabilização
Solução
- O ponto central é não deixar que sistemas de build como
npmecargobusquem automaticamente dependências em locais de rede toda vez, e sim colocar todas as dependências junto com o software - Vendorize todas as dependências no projeto, copiando o conteúdo do controle de versão do upstream para o repositório git e fazendo commit
- Quando houver atualizações no upstream, basta baixá-las e copiar de novo; se o trabalho manual ficar cansativo, ferramentas de build podem automatizar isso
- Se já houver um lockfile, ele pode ser ligado à árvore completa de código-fonte dentro do controle de versão
- Passa-se a possuir o controle de cada linha de código-fonte de forma forte
-
Custos e trade-offs
- O repositório cresce, mas espaço em disco é barato
- O custo de transferência é menos barato que disco, mas nesta discussão continua sendo algo a aceitar
- O tempo de build parece que vai aumentar, mas isso não necessariamente acontece, porque de qualquer forma aquelas dependências já estavam sendo recompiladas
- Reutilizar código pode ficar mais difícil, e isso pode ser um problema real em programas como cliente e servidor que usam bibliotecas compartilhadas de protocolo
- Esses programas já têm problemas de incompatibilidade de versão e já precisam lidar com isso, então na prática forçar mais atenção ao tema não é pior no longo prazo
-
Corta-fogo contra ataques à cadeia de suprimentos
- Se as dependências não forem atualizadas automaticamente, cada pacote do ecossistema vira um corta-fogo contra ataques à cadeia de suprimentos
- O mesmo mecanismo também bloqueia a propagação de correções de bugs e patches, mas se a correção for importante, alguém de qualquer forma vai procurá-la manualmente
- Correções que ninguém vai procurar manualmente em geral costumam não ser importantes
- Também é possível obter efeito parecido abandonando no sistema de build o semver ou a ideia de que “dois códigos diferentes deveriam se comportar da mesma forma”, tratando todos os números de versão como identificadores únicos e sem relação entre si
- O problema do semver é que ele expressa intenção humana, não a realidade concreta, e mesmo isso só funciona quando é usado de forma ao menos razoavelmente correta
- Tratar números de versão como únicos não resolve problemas como dependências que desaparecem, são adulteradas ou têm seu conteúdo corrompido de outras maneiras
-
Visibilidade das dependências
- Vendorizar todas as dependências, além de desacelerar mudanças automáticas, também eleva um pouco o custo de usá-las
- Esse aumento de custo não é irrecuperável; ele apenas faz a pessoa pensar um pouco mais antes de usar código upstream
- Funciona como um mecanismo suave para voltar a perguntar “isso é mesmo necessário?” ao adicionar uma nova dependência
- A visibilidade das dependências aumenta, e o inchaço escondido atrás delas fica menos oculto
- Se você adicionou uma biblioteca simples que parecia ter umas 200 linhas, mas ela tinha 50.000, fica mais claro que é hora de parar e perguntar por quê
- O caráter quase mágico das dependências diminui, e fica mais fácil rastrear dentro da base de código por onde um bug chega ao código de outras pessoas
-
Árvore de dependências e problema de compartilhamento
- Vendorizar tudo por padrão pode incentivar árvores de dependência mais planas e largas
- Não é desejável chegar ao nível de bibliotecas gigantes como Boost ou Qt em C++
- Bibliotecas gigantes assim existem porque criar e usar pequenas bibliotecas em C/C++ é difícil demais
- Parte-se da ideia de que, em vez de cada um tentar descobrir como compilar algo como Boost ou Qt, é melhor que um integrador de sistemas como uma distribuição Linux faça isso uma vez só
- A desvantagem real é que dependências transitivas deixam de ser compartilhadas
- Se a lib A e a lib B dependem de Z, eliminar duplicação não é impossível, mas fica mais difícil e exige trabalho manual ou ferramentas mais sofisticadas
- Mesmo quando dependências transitivas são compartilhadas, ainda surgem problemas, e ter dependências transitivas já é parte do problema em si
- Permitir que bibliotecas especifiquem dependências transitivas é entregar a outras pessoas o controle sobre o seu programa
Análise
- Nem todo software pode adotar essa abordagem
- Vendorizar e compilar um Redis inteiro como parte do deploy de um backend web não é algo particularmente razoável
- Ainda assim, se o deploy já for automatizado com Ansible ou imagens Docker, é possível que na prática você já esteja fazendo algo parecido
- Existe um limite para a complexidade que essa abordagem consegue suportar, mas empresas de monorepo gigante como Google e Facebook mostram que esse limite pode ser mais alto do que parece
- Em algum ponto, as dependências encontram o sistema operacional, e o sistema operacional é uma dependência grande com seus próprios muitos problemas
- A ideia de unikernel para backends web é atraente, mas há problemas reais de tooling e ainda não chegamos lá
-
Distribuições Linux e ambiente de build
- Essa abordagem não é uma forma de construir sistemas completos e interativos como uma distribuição Linux ou BSD
- Esses sistemas pertencem a outro tipo de problema, porque envolvem muitos programas e bibliotecas que precisam funcionar juntos
- Levar esse princípio até o fim aproxima a abordagem de algo como Nix ou Guix
- A ideia de “montar corretamente o ambiente de build” se parece mais com uma solução preguiçosa e insuficiente para o problema de “como compilar software” do que com uma resposta adequada
- Essa ideia é um resquício de uma época em que o software era compilado uma vez em algum minicomputador e depois amplamente distribuído em binário
- Hoje compilamos software sob demanda em escala muito maior do que nos anos 1970
-
Escopo de aplicação
- Essa abordagem não é uma solução universal, mas pode ser aplicada com benefícios a muitos tipos de software
- A maior parte do software é pequena, e projetos grandes já precisam resolver bastante desses problemas de qualquer maneira
- Há muitas bibliotecas que só fazem computação pura ou que só tocam o mundo externo por I/O básico e portátil, como arquivos e sockets de rede
- Bibliotecas de compressão, libcurl, bibliotecas de TUI e Django são exemplos que podem ser tratados como alvo de vendorization
- Ao vendorizar, é possível evitar quase totalmente que deploys ou builds em sistemas novos quebrem sem explicação por causa de conflito de versão ou bugs introduzidos por patches repentinos
- O objetivo é reduzir as dependências que podem mudar sem aviso do lado de fora, não para 200 ou 300, mas para no máximo 2 ou 3
Conclusão
- Reduzir a atualização automática de dependências e fazer o projeto manter também o código-fonte delas pode desacelerar a propagação automática de ataques à cadeia de suprimentos
- Aumentar um pouco o custo de usar dependências e melhorar sua visibilidade facilita encontrar reutilização desnecessária e inchaço escondido
- Essa abordagem não serve para todos os sistemas, mas traz vantagens práticas para softwares pequenos e para muitas bibliotecas
1 comentários
Comentários no Lobste.rs
Acho que o gerenciador de pacotes do Zig é um meio-termo bem razoável
Todo pacote fica fixado por um hash de conteúdo, então é como se já existisse um lockfile por padrão, evitando o problema de “o repositório upstream de repente ficar malicioso”, embora o problema de “o repositório upstream desaparecer” continue existindo
Ainda assim, como há cache global e local, e tudo é baseado em hash de conteúdo, se o repositório upstream sumir basta jogar o tarball da cópia local onde for necessário
Parece um bom compromisso entre “vendorizar o código-fonte” e “software simples e reutilizável”
Colocando todo o código-fonte em um repositório endereçado por conteúdo, e cada programa poderia ser hasheado com base no hash de suas entradas
Provavelmente seria preciso modificar o lockfile ou encontrar uma colisão de hash, e nenhuma das duas parece fácil
Ainda assim, por estar acostumado ao ecossistema do
cargo, isso não me agrada totalmente. Quando você sobe uma dependência, as dependências transitivas dela também tendem a subir junto sem muito aviso, e outras coisas dentro do intervalo de versionamento semântico também acabam mudandoPara eu chamar isso de “ataque à cadeia de suprimentos”, teria que haver um contrato assinado com proposta e contraprestação, então eu não consideraria isso uma cadeia de suprimentos
Separadamente, do ponto de vista de garantir que uma dependência não mude por baixo, um lockfile com hashes ou o método de seleção de versão mínima do Go equivale a vendorizar dependências
Entendo a diferença de que vendorizar cria atrito, mas, levado ao extremo, isso acaba levando a implementar tudo na mão ou, pior, a transformar dependências em código gerado ad hoc, então me parece melhor usar software escrito por especialistas no domínio e suficientemente validado
Trabalhei com isso no Facebook, e não recomendaria a ninguém a forma como eles fazem gerenciamento de dependências de terceiros. Para dependências diretas de um determinado crate Rust, no fbsource inteiro só são permitidas ao mesmo tempo no máximo duas versões semanticamente incompatíveis. Se você quiser atualizar uma dependência, precisa assumir o custo de atualizar o fbsource inteiro
Pode ser um modelo que funciona para o Facebook, mas não me parece particularmente excelente nem sustentável
Suspeito que o comentário de que “não é particularmente excelente nem sustentável” tenha mais a ver com escala do que com a política em si. Permitir várias versões também cria outros problemas, porque a maioria das linguagens modernas, com exceção do TypeScript, usa tipos nominais principalmente ou exclusivamente, então cada mudança incompatível impede o reuso de tipos entre versões, a menos que se use o “semver trick”
Na época do Log4Shell, lembro claramente que empresas com muitas versões espalhadas por vários lugares tiveram mais dificuldade para fazer upgrade do que empresas com poucas versões ou versões já fixadas
Segundo The Third Networking Truth, “Com impulso suficiente, porcos voam muito bem. No entanto, isso não quer dizer necessariamente que seja uma boa ideia”
Muitas práticas citadas em lugares como Google/Facebook só funcionam porque essas empresas podem colocar impulso suficiente nisso
Por exemplo, sei de alguns desses lugares que mantêm uma equipe maior do que a empresa inteira em que trabalho só para sustentar o monorepo e as escolhas relacionadas a dependências. Eles conseguem bancar isso, mas para a maioria de nós é difícil
Boa análise. Concordo fortemente com a ideia de que “vendorizar todas as dependências aumenta o custo de usar dependências”
Mas você não deveria copiar e colar o libcurl. Para a maioria das bibliotecas isso é uma estratégia aceitável, mas não é um bom conselho para programas em C que lidam com entrada hostil. Você não vai conseguir manter o libcurl seguro melhor do que o sistema operacional
Um ponto em que eu nunca tinha pensado é que talvez seja pelo menos um pouco estranho que gerenciadores de pacotes para usuário final, como o apt, tenham surgido antes, e só depois vieram os gerenciadores de pacotes em nível de linguagem
Acho que isso de fato causou muitos problemas. Se você olhar para o rubygems do começo dos anos 2000, fica bem claro que a ideia era fazer um “apt para Ruby”, com instalação no sistema inteiro por padrão, e não gerenciamento por projeto. Foram necessárias décadas e a adição do bundler para desfazer o dano desse erro, mas, se a necessidade de isolamento por projeto tivesse sido reconhecida desde o início, o bundler nem teria sido necessário
O Python ainda está tentando arrumar essa bagunça, e imagino que o Perl também esteja, embora eu não saiba em detalhe
Historicamente, gerenciadores de pacotes eram originalmente uma forma de construir sistemas, e esses sistemas tinham vários usuários, ambientes de desktop e muito software funcionando em conjunto
Compilar software custava muito tempo e memória, e havia muito software em relação a disco e RAM, então o reuso de bibliotecas era importante
Com a ascensão dos web apps, a maior parte dos computadores importantes passou a ser servidor rodando só poucos programas durante toda a vida, e disco e RAM ficaram baratos o bastante para que o tamanho dos binários importasse menos
As ferramentas para construir sistemas não acompanharam totalmente essa mudança de época, e por isso a maioria das pessoas que desenvolvem software passou a precisar só de ferramentas para fazer bem um único programa, e não um enorme sistema interconectado cheio de bibliotecas compartilhadas
Em paralelo a essa história também existe a linha de que “C não tem um sistema de módulos decente”, mas isso é menos importante aqui
Posso estar enganado, mas parece haver a desvantagem de que, mesmo que uma dependência copiada tenha um bug, o scanner pode não detectá-lo
Nesse caso, um problema potencial sobre o qual você normalmente receberia um alerta poderia permanecer em silêncio
Scanners são muito úteis para mostrar coisas que podem ser um problema, mas é bem irritante quando fazem você adiar de repente o trabalho planejado para corrigir algo que o scanner achou problemático, mas que na prática não é
Se você incluir todas as dependências no software como proposto, copiar o gerenciamento do código-fonte upstream para dentro de um repositório git e dar commit nisso, e deixar uma ferramenta de build automatizar tudo quando o trabalho manual ficar cansativo, no fim das contas você não está dando a volta completa e voltando a incluir software de terceiros sem realmente olhar para ele?
Mas essa abordagem não resolve o problema de dependências sumirem ou serem adulteradas, nem o problema de alguém mexer no conteúdo do pacote de outras formas. É mais uma otimização e, na minha opinião, uma otimização prematura. Talvez um dia se chegue lá, mas isso não deveria ser o ponto de partida