Segurança em Rust
LidoEsta aula explora a segurança em Rust, distinguindo as garantias de memória do compilador das responsabilidades do programador. Você aprenderá a validar entradas, auditar dependências com cargo-audit e adotar boas práticas para escrever código Rust seguro e confiável.
Bem-vindo à aula sobre Segurança em Rust! Rust é frequentemente elogiado por suas garantias de segurança de memória, mas é crucial entender o que isso realmente significa e onde o programador ainda é responsável. Nesta aula, vamos explorar os limites das garantias do Rust, como validar entradas de forma robusta, como gerenciar dependências com ferramentas como cargo-audit e quais boas práticas adotar para escrever código seguro.
Vamos começar desmistificando a segurança em Rust: o compilador é um aliado poderoso, mas não é uma varinha mágica. A segurança é um esforço conjunto entre a linguagem, as ferramentas e o programador.
O que o Rust garante e o que não
O Rust garante segurança de memória em tempo de compilação, prevenindo erros como uso após liberação (use-after-free), dupla liberação (double free), desreferenciamento de ponteiros nulos e corridas de dados (data races). Isso é alcançado através do sistema de ownership, borrow checker e regras de lifetimes. O compilador rejeita código que violaria essas regras, garantindo que, se o código compilar, ele estará livre dessas classes de bugs.
No entanto, Rust não garante segurança lógica, segurança de API, nem proteção contra todos os tipos de ataques. Por exemplo, o Rust não impede que você escreva uma função que aceite qualquer input e processe de forma incorreta, nem previne vazamentos de informação se você não tratar dados sensíveis adequadamente. Além disso, a segurança de memória pode ser contornada com unsafe, que é necessário para interagir com código externo ou implementar abstrações de baixo nível, mas que exige cuidado redobrado.
Vamos ver um exemplo de código que compila, mas é inseguro logicamente:
fn divide(a: f64, b: f64) -> f64 {
a / b
}
fn main() {
let result = divide(10.0, 0.0); // Isso compila, mas gera infinito
println!("{}", result);
}
Esse código compila sem problemas, mas a divisão por zero resulta em inf (infinito), o que pode causar comportamentos inesperados em sistemas críticos. Rust não impede isso porque não é um erro de memória, mas sim de lógica.
Validação de entrada
A validação de entrada é uma das primeiras linhas de defesa em qualquer sistema. Em Rust, você pode usar tipos fortes e pattern matching para garantir que os dados recebidos sejam válidos. Por exemplo, em vez de aceitar um String genérico para um email, você pode criar um tipo Email que valida o formato na sua construção, tornando impossível ter um email inválido em seu sistema.
Além disso, para entradas numéricas, você pode usar tipos como u32 para garantir que não haja valores negativos, mas ainda precisa verificar limites superiores se necessário. Para entradas de texto, você deve validar tamanho, caracteres permitidos e formatos específicos. O crate validator é uma ótima opção para validações derivadas com atributos.
Exemplo de validação manual:
use std::net::IpAddr;
fn parse_ip(input: &str) -> Result<IpAddr, String> {
input.parse::<IpAddr>().map_err(|_| format!("IP inválido: {}", input))
}
fn main() {
match parse_ip("192.168.0.1") {
Ok(ip) => println!("IP válido: {}", ip),
Err(e) => println!("Erro: {}", e),
}
}
Aqui, a validação é feita pelo parser do próprio Rust, que só aceita endereços IP válidos. Isso evita que você precise implementar sua própria lógica de parsing, reduzindo o risco de erros.
Dependências (cargo audit)
As dependências são uma superfície de ataque significativa. Mesmo que seu código seja perfeito, uma dependência vulnerável pode comprometer seu sistema. O Rust tem um ecossistema de crates enorme, e é essencial auditar as dependências regularmente. A ferramenta cargo-audit verifica seu Cargo.lock contra um banco de dados de vulnerabilidades conhecidas (RustSec Advisory Database) e reporta qualquer problema.
Para instalar e usar:
cargo install cargo-audit
cargo audit
O comando cargo audit examina todas as dependências (incluindo transitivas) e lista vulnerabilidades com seus CVE IDs, gravidade e versões corrigidas. Você deve executar isso regularmente, idealmente em um pipeline de CI.
Além disso, mantenha suas dependências atualizadas com cargo update e use versões semver compatíveis. Considere também usar cargo deny para bloquear dependências com licenças indesejadas ou com vulnerabilidades conhecidas, como uma camada extra de proteção.
Exemplo de saída do cargo audit (simulada):
Fetching advisory database...
Scanning Cargo.lock for vulnerabilities (82 dependencies)
Vulnerability: https://rustsec.org/advisories/RUSTSEC-2024-0001
Severity: high
Affected versions: <1.2.0
Patched versions: >=1.2.0
Se uma vulnerabilidade for encontrada, você deve atualizar a dependência para uma versão corrigida ou, se não houver correção, avaliar o risco e possivelmente substituí-la.
Boas práticas
Além das ferramentas, existem boas práticas que você deve adotar ao escrever código Rust para maximizar a segurança. Primeiro, evite unsafe sempre que possível. Se você precisar usá-lo, documente extensivamente por que é seguro e mantenha o bloco unsafe pequeno e isolado. Segundo, use tipos fortes para representar dados do domínio, como structs e enums, em vez de usar primitivos genéricos.
Terceiro, trate erros explicitamente com Result e Option, evitando unwrap e expect em código de produção, pois eles podem causar panics. Quarto, valide todas as entradas externas, incluindo argumentos de linha de comando, dados de rede e arquivos. Use crates como clap para argumentos e serde para desserialização com validação.
Quinto, mantenha suas dependências mínimas e confiáveis. Verifique a popularidade, manutenção e segurança de cada crate antes de adicioná-lo. Sexto, escreva testes, incluindo testes de propriedade com proptest, para verificar que seu código se comporta corretamente em casos extremos.
Exemplo de uso de tipo forte e validação:
#[derive(Debug)]
struct Username(String);
impl Username {
fn new(s: &str) -> Result<Self, String> {
if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric()) {
Ok(Username(s.to_string()))
} else {
Err("Username deve ter pelo menos 3 caracteres alfanuméricos".to_string())
}
}
}
fn main() {
let user = Username::new("joao123").unwrap();
println!("{:?}", user);
}
Essa abordagem garante que, uma vez criado, um Username sempre seja válido, eliminando a necessidade de validar em cada uso.
Referências
- The Rust Book: Ownership
- Rust Reference: unsafe keyword
- RustSec Advisory Database
- cargo-audit no crates.io
- Cargo Documentation
- validator crate documentation
- Rust by Example: Result
Exercícios
- Explique por que o Rust não impede a divisão por zero e como você poderia usar tipos para garantir que isso não aconteça em tempo de compilação, se possível.
- Escreva uma função em Rust que valide uma senha com os seguintes requisitos: pelo menos 8 caracteres, contém pelo menos uma letra maiúscula, uma minúscula, um dígito e um caractere especial. Use
Resultpara retornar erros descritivos. - Você está usando um crate com uma vulnerabilidade conhecida. Descreva o processo passo a passo para resolver isso usando cargo-audit e outras ferramentas.
- Dado o seguinte código, identifique possíveis problemas de segurança e sugira melhorias:
NonZeroF64 que só pode ser criado com valores diferentes de zero, tornando a divisão segura por construção.fn validate_password(pw: &str) -> Result<(), String> {
if pw.len() < 8 { return Err("Senha deve ter pelo menos 8 caracteres".to_string()); }
if !pw.chars().any(|c| c.is_uppercase()) { return Err("Senha deve conter uma letra maiúscula".to_string()); }
if !pw.chars().any(|c| c.is_lowercase()) { return Err("Senha deve conter uma letra minúscula".to_string()); }
if !pw.chars().any(|c| c.is_digit(10)) { return Err("Senha deve conter um dígito".to_string()); }
if !pw.chars().any(|c| "!@#$%^&*".contains(c)) { return Err("Senha deve conter um caractere especial".to_string()); }
Ok(())
}cargo audit para identificar a vulnerabilidade. 2. Verifique o aviso: ele mostra o crate afetado, a versão e a versão corrigida. 3. Atualize a dependência no seu Cargo.toml para a versão corrigida (por exemplo, crate = "1.2.0"). 4. Execute cargo update -p crate para atualizar o Cargo.lock. 5. Execute cargo audit novamente para confirmar que a vulnerabilidade foi resolvida. 6. Se não houver versão corrigida, considere substituir o crate ou, em última instância, aceitar o risco com mitigação.fn process_user_input(input: &str) -> u32 {
input.trim().parse::<u32>().unwrap()
}
unwrap() causará panic se a entrada não for um número, o que pode derrubar o programa. 2. Não há validação de limites (por exemplo, se o número é muito grande). 3. A função não retorna um Result, então o chamador não pode tratar erros. Melhorias: use Result<u32, String> e retorne um erro descritivo. Além disso, valide se o número está dentro de uma faixa aceitável, se necessário.std::fs e Result.use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
fn main() {
match read_file("config.txt") {
Ok(contents) => println!("{}", contents),
Err(e) => eprintln!("Erro ao ler arquivo: {}", e),
}
}Boas práticas e observações finais
Em resumo, a segurança em Rust é uma combinação de garantias do compilador e de práticas diligentes do programador. Lembre-se de que o Rust não é uma bala de prata; ele fornece uma base sólida, mas você deve construir sobre ela com validação de entrada, auditoria de dependências e código defensivo. Sempre use ferramentas como cargo-audit e cargo-deny em seu fluxo de trabalho, e nunca subestime a importância de testes e revisão de código.
Espero que esta aula tenha esclarecido o panorama da segurança em Rust e que você se sinta mais preparado para escrever código robusto. Continue explorando a documentação oficial e as ferramentas da comunidade para aprofundar seu conhecimento.