No post sobre a migração para o Gitea contei como fiz os builds dos meus projetos funcionarem dentro do cluster. Para isso, peguei um atalho: o runner do Gitea (o programa que executa os workflows de CI) ganhou muito mais privilégios do que precisava.

  • o socket do containerd, o arquivo por onde se dão ordens ao programa que roda os containers no nó. Quem escreve nele pode pedir um container rodando como root e com o disco inteiro do Pi montado;
  • a pasta onde ficam as imagens e os dados do cluster;
  • o kubeconfig de administrador, a credencial que deixa o kubectl fazer qualquer coisa: ler senhas, criar e apagar qualquer recurso.

Funcionava, mas qualquer workflow de qualquer repositório tinha acesso ao cluster inteiro. Basta um npm ci instalar uma dependência comprometida: o script dela roda dentro do job e só precisa ler o kubeconfig que está ali.

Enquanto o único usuário sou eu, dá para conviver com isso. Só que hoje o Pi serve este site pela internet e guarda meus repositórios, a memória das minhas conversas com IA e os registros de uso dos projetos. Resolvi tratá-lo como produção, com três regras: cada componente recebe só a permissão de que precisa (o princípio do menor privilégio); a proteção vem em camadas, para que a falha de uma não entregue tudo; e toda mudança é testada antes e depois.

Este é o primeiro de três posts e trata de dificultar um ataque. O segundo vai tratar de perceber que alguém escapou de um container, e o terceiro, de ver quem acessa os sites vindo da internet e como bloquear quem abusa.

Havia uma restrição: o Pi tem 4 GB de RAM, com cerca de 1,2 GB livres. Ferramentas comuns nesse assunto, como Kyverno, Calico ou Falco, rodam como pods próprios e gastam memória que eu não tenho. Por isso usei só o que já vem no Kubernetes e no k3s. Nenhuma das fases abaixo acrescentou um pod.

Em Resumo

  • Antes: qualquer job de CI tinha, na prática, acesso de administrador ao cluster e ao Pi.
  • Depois: cada tipo de job recebe só o que precisa; nenhum pod monta nada do host; cada pod só fala com quem precisa; o kernel limita o que cada container faz; e quem escapar de um container cai no Pi como um usuário sem privilégio nenhum.
  • Custo: zero pods a mais. Os runners passaram a usar menos memória (de ~83 MiB para 7 a 9 MiB cada).
  • Bônus: o teste de uma das mudanças achou um defeito no backup que deixaria o Gitea quebrado depois de qualquer restauração.

O ponto de partida: um Pod por job

Em outubro saíram o Gitea 28.1.0, com correção de 9 CVEs, e o Gitea Runner 5.0.0, sucessor do act_runner. No dia 13 atualizei os dois (o Gitea vinha da 1.27.3; o runner, da 0.6.1).

O resto do trabalho partiu de um recurso da versão nova do runner: o modo kubernetes://, em que cada job roda num pod próprio, criado na hora e descartado ao terminar. Do jeito que eu usava o act_runner antigo, os jobs rodavam dentro do container do próprio runner e herdavam tudo o que ele tinha montado. Um job de teste tinha o mesmo acesso que um de deploy. No modo novo, o que cada pod recebe vem de um template na configuração do runner, um por tipo de job, e o workflow do repositório não consegue pedir nada além disso.

Assim, cada tipo de job pode ter permissões diferentes. O de teste não precisa de credencial nenhuma; o de build precisa construir uma imagem; o de deploy precisa aplicar manifestos no cluster. As seis fases tratam disso, uma parte por vez:

FaseO problemaO recurso usado
1o deploy usava o kubeconfig de administradorRBAC
2o build usava o socket do containerd do hostBuildKit rootless + registry do Gitea
3nada impedia um pod novo de pedir privilégiosPod Security Admission
4qualquer pod conseguia falar com qualquer outroNetworkPolicy
5root dentro do container era root no Piuser namespaces
6dentro do container, nada limitava o que um processo pedia ao kernelAppArmor

Fiz as cinco primeiras entre 13 e 15 de outubro. No dia 16, troquei a imagem do Gitea, o último container meu que ainda rodava como root. A sexta veio no dia 29.


Fase 1: Implantar RBAC no Cluster

O que é

Todo pod que fala com a API do Kubernetes usa uma identidade, a ServiceAccount. O RBAC (role-based access control) define o que cada identidade pode fazer: em quais tipos de recurso (Deployment, Secret, Pod…), com quais ações (ler, criar, alterar, apagar) e em quais namespaces. As permissões ficam num Role ou ClusterRole, e um RoleBinding as entrega a uma identidade, valendo só no namespace onde foi criado. Isso permite escrever as regras uma vez e aplicá-las só onde fazem sentido.

O que fiz

O deploy precisa aplicar Deployment, Service, IngressRoute e mais alguns recursos, e só nos namespaces dos apps. Criei uma ServiceAccount para cada papel:

ServiceAccountQuem usaPodeNão pode
gitea-runnero runnercriar e apagar pods de job, só em cinada fora de ci
ci-deployer-appsdeploy dos appsaplicar Deployment, Service, ConfigMap, PVC, Ingress e IngressRoute em apps, dev e prdler Secrets, apagar, mexer em outros namespaces
ci-deployer-docsdeploy dos sites de documentaçãoo mesmo, só em docso mesmo

As regras dos dois deploys ficam num único ClusterRole. Um trecho:

kind: ClusterRole
metadata:
  name: ci-deployer
rules:
  # `kubectl apply` = get + create (se não existe) ou patch (se existe).
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "create", "patch", "update"]
  - apiGroups: ["traefik.io"]
    resources: ["ingressroutes"]
    verbs: ["get", "list", "watch", "create", "patch", "update"]

Cada ServiceAccount recebe esse ClusterRole por RoleBindings, um em cada namespace onde pode agir: três para a ci-deployer-apps (apps, dev, prd) e um para a ci-deployer-docs.

Deixei duas permissões de fora de propósito:

  • Apagar (delete). O deploy aplica manifestos; remover algo é decisão minha, feita à mão. Se o token vazar, o pior que dá para fazer é aplicar um manifesto ruim, que o próximo deploy sobrescreve.
  • Ler Secrets. O manifesto do app cita o Secret pelo nome, e quem lê o valor é o kubelet, na hora de subir o pod. O deploy nunca precisa ver o conteúdo.

Uma brecha eu só encontrei dias depois. O RBAC diz em quais tipos de recurso e em quais namespaces uma identidade pode agir, mas não distingue um objeto do outro pelo nome. Como o Gitea também fica em apps, o deploy de qualquer app conseguia trocar a imagem do Gitea. A correção, uma ValidatingAdmissionPolicy, fica para o terceiro post da série.

Os jobs de teste ficaram sem ServiceAccount nenhuma. Eles rodam em pull request, ou seja, com código que ainda não foi revisado, e agora não há token no pod para ser roubado.

Como conferir

kubectl auth can-i --list -n apps --as=system:serviceaccount:ci:ci-deployer-apps

O --as faz o kubectl se passar pela ServiceAccount e listar o que ela pode naquele namespace, sem rodar job nenhum.

Antes, um script malicioso no deploy conseguia ler todos os Secrets do cluster (tokens dos túneis, senhas do Postgres), criar um pod privilegiado ou apagar o que quisesse. Agora consegue aplicar alguns tipos de manifesto em três namespaces. Só que o build ainda tinha o socket do containerd, e por ele dava para fazer tudo isso sem passar pelo RBAC.


Fase 2: build sem o socket do host

O problema

Construir uma imagem é executar os passos do Dockerfile, e cada RUN precisa de um container. O jeito mais simples é pedir esses containers ao containerd do próprio nó, pelo socket. O problema é que o containerd não sabe para que serve cada container que pedem a ele. Para ele, um passo de build e um container privilegiado com o disco do Pi montado são pedidos iguais, e quem tem acesso ao socket consegue qualquer um dos dois. Esse pedido também vai direto ao containerd, sem passar pela API do Kubernetes, então o RBAC nem fica sabendo.

O que fiz

Troquei pelo BuildKit, o motor de build que o próprio Docker usa, na versão rootless. Ele roda como um segundo container dentro do pod do job, sem nada do host, e cria os containers de cada passo do build por conta própria, sem pedir nada ao containerd do nó. A imagem pronta vai para o registry de containers que já vem no Gitea, e o nó a baixa de lá, como baixaria do Docker Hub.

Apareceram dois obstáculos:

  • Seccomp. O seccomp é um filtro do kernel que define quais chamadas de sistema um processo pode fazer. O perfil padrão do Kubernetes bloqueia justamente as que o BuildKit usa para isolar os passos do build. Desligar o filtro resolveria, mas a Fase 3 proíbe isso. Escrevi um perfil próprio, que libera o que o BuildKit precisa e continua bloqueando o que um build não tem necessidade de fazer, como carregar módulos no kernel ou mudar o relógio.
  • Quem publica a imagem. Enviar a imagem ao registry exige um token. Em vez de usar um meu, criei um usuário ci-bot, sem permissão de administrador, que só pode publicar pacotes das organizações que fazem build. Se esse token vazar, ele não alcança código nem configuração.

Aproveitei para mudar como o deploy se refere à imagem. Antes era pela tag (site-chat:<sha>); agora é pelo digest, o hash do conteúdo (site-chat:<sha>@sha256:…). Quem pode publicar no registry pode fazer uma tag apontar para outra imagem; um digest identifica um conteúdo exato e não muda. Assim o pod roda exatamente a imagem que o build produziu.

Depois disso, os pods dos runners ficaram sem modo privilegiado, sem o socket, sem o kubeconfig e sem nenhuma pasta do host montada (hostPath). Um build do site-chat sem cache leva 2min10s, com pico de 114 MiB, dentro do limite de memória do pod. O cache do modo antigo, que não tinha limite e já tinha chegado a 16 GB no cartão SD, foi apagado. Os runners, que agora só esperam jobs, caíram de ~83 MiB para 7 a 9 MiB cada.

Faltava garantir que nenhum desses acessos voltasse, nem por descuido meu.


Fase 3: Pod Security Admission

O que é

O Pod Security Admission é uma checagem que já vem no API server: antes de criar um pod, ele confere se o pod respeita o nível de segurança do namespace. São três níveis:

  • privileged: sem restrição.
  • baseline: barra o que dá acesso direto ao host, como container privilegiado, pasta do host montada, usar a rede ou os processos do nó, poderes extras de root (capabilities) e seccomp desligado.
  • restricted: tudo o que o baseline exige e mais: rodar sem root, sem nenhuma capability, sem poder ganhar privilégio depois e com seccomp ligado.

O nível é um label no namespace:

kind: Namespace
metadata:
  name: prd
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.35

O enforce faz o API server recusar pods novos que não cumprem o nível. O enforce-version fixa as regras de uma versão do Kubernetes; sem ele, um upgrade do k3s poderia endurecer as regras sozinho e começar a recusar pods que funcionavam.

Como apliquei

Mudar o label não afeta os pods que já estão rodando; só os novos são checados. Mesmo assim, apertar o nível às cegas é garantir que o próximo deploy falhe. O kubectl mostra antes o que seria recusado, sem mudar nada:

kubectl label --dry-run=server --overwrite ns <ns> pod-security.kubernetes.io/enforce=restricted

Fui um namespace por vez. Cada um começou no nível que os pods dele já cumpriam e subiu quando todos estavam prontos:

NamespaceComeçou emEstá emO que permitiu subir
infrarestrictedrestrictedo Postgres já rodava sem root
docs, dev, prdbaselinerestrictedsites e túneis passaram a rodar sem root
mcpprivilegedrestricteda pasta do host deixou de ser montada direto no pod e virou um PersistentVolume
appsbaselinerestrictedo Gitea na imagem rootless (mais abaixo)
alertsrestrictedrestrictedcriado depois, para o serviço de alertas, já sem root
ciprivilegedbaselinea Fase 2 tirou todo acesso ao host

O ci fica em baseline porque os pods de teste rodam imagens como root, e o BuildKit precisa rodar como root dentro do pod (o porquê está na Fase 5). O restricted não permite nenhum dos dois.

Pod Security x RBAC

O RBAC da Fase 1 continua deixando o deploy criar Deployments em apps. Sem o Pod Security, um deploy comprometido poderia criar ali um pod que monta o disco do Pi. Um complementa o outro: o RBAC diz onde o deploy pode mexer, e o Pod Security limita o que os pods criados ali podem pedir.


Fase 4: NetworkPolicy

O que é

Por padrão, a rede do Kubernetes é toda aberta: qualquer pod abre conexão com qualquer outro, em qualquer namespace. Até 14 de outubro era assim aqui. Um túnel da Cloudflare conseguia falar com o Postgres, e um job de teste, com o SSH do Gitea.

A NetworkPolicy é um firewall entre pods. A partir do momento em que um pod é coberto por alguma policy, só passa o tráfego que alguma policy libera. O comum é começar cada namespace bloqueando tudo e ir abrindo só o necessário. Quem aplica as regras é o kube-router, que já vem dentro do k3s, então não precisei instalar nada.

Um exemplo é o namespace prd, onde ficam os túneis da Cloudflare. Um túnel precisa de DNS, da Cloudflare e do caminho até os sites que serve, e de mais nada:

# Bloqueia tudo, entrada e saída, para todos os pods do namespace.
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: prd
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
# Libera a saída para a Cloudflare: a internet sem as faixas privadas,
# só nas portas que os túneis usam.
kind: NetworkPolicy
metadata:
  name: tunnel-egress
  namespace: prd
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16]
    ports:
    - {port: 7844, protocol: UDP}
    - {port: 7844, protocol: TCP}
    - {port: 443, protocol: TCP}
  # (o arquivo completo libera também o DNS e a porta dos túneis no Traefik)

Os IPs da Cloudflare mudam, por isso a regra é pela porta e não pelo endereço.

Como ficou

NamespaceQuem pode entrarPara onde pode sair
infra, mcp (Postgres, Mempalace)só a rede de casa e a VPN privada; nenhum pod do clusterqualquer lugar
cininguémDNS e Gitea; testes e builds, também a internet; deploy, também a API do Kubernetes
apps, docssó o proxy reverso; entre apps, só os caminhos declaradosqualquer lugar
prd, dev (túneis)ninguémDNS, a Cloudflare e uma porta do proxy reservada aos túneis
alerts (serviço de alertas)ninguémDNS e a internet, só na porta 443

Na primeira versão, os túneis falavam direto com os pods dos sites. Hoje passam por uma porta própria do proxy, onde só existem as rotas publicadas na internet. O motivo foi conseguir ver quem acessa os sites, e fica para o terceiro post da série.

O que aprendi no caminho

Antes de mexer em produção, montei um laboratório com namespaces temporários para ver como o kube-router se comporta de verdade. Três lições que valem para qualquer cluster:

  • A NetworkPolicy só enxerga IP e porta, não o site pedido. Para os jobs alcançarem o registry, liberei o Traefik, o proxy que recebe as requisições e encaminha cada uma para o site certo. Com isso, liberei junto todos os sites internos atrás dele: num teste, um job conseguiu listar os arquivos do meu editor de textos. A correção foi criar um Service só para o registry, que os jobs acessam direto, sem passar pelo Traefik.
  • Na dúvida, bloquear. Cada pod de job recebe um label com o seu papel (ci-role: test, build ou deploy), e a rede é liberada de acordo com ele. Um pod sem papel conhecido só alcança o DNS e o Gitea. Se eu esquecer o label num template de job novo, o job falha e eu percebo, em vez de ele ganhar acesso à internet sem ninguém notar.
  • Regras por tipo de rede, nunca pelo IP de uma máquina. As policies falam em “redes privadas”, “VPN privada” e “rede interna do cluster”, nunca no IP do Pi ou na faixa do meu roteador. Se eu trocar de roteador, nada precisa mudar.

Como conferir

Uma regra liberando demais não dá sintoma nenhum: tudo continua funcionando. Por isso escrevi o netpol_check.sh, que cria pods temporários com os mesmos labels dos pods reais e testa cada caminho que deveria passar e cada um que deveria ser bloqueado. Começou com 84 casos e hoje tem 110. Rodo de novo depois de qualquer mudança nas policies, nos templates de job ou na versão do k3s.

Na prática: se o cloudflared, único pod que recebe tráfego da internet, for invadido, ele só alcança a Cloudflare e a porta do proxy reservada aos túneis. Não chega ao Postgres, ao Gitea nem à API do Kubernetes. E nenhum pod de job ou de túnel alcança a rede de casa, então um container invadido não serve de ponte para atacar os outros dispositivos.


Entre as fases: dois problemas fora do plano

Pensando no que um processo encontraria se escapasse de um container, achei dois problemas que não estavam em fase nenhuma.

O kubeconfig de administrador (/etc/rancher/k3s/k3s.yaml) podia ser lido por qualquer usuário do Pi, inclusive pelos usuários com que os containers rodam. Agora só o root lê.

A versão do k3s não estava fixada, e uma reinstalação pegaria a versão estável do dia. Agora está, e um script de upgrade faz backup do banco do cluster, instala a versão nova e confere que tudo voltou. Depois do upgrade para a 1.35.9, rodei de novo os 84 testes de rede.


Fase 5: user namespaces

O que é

As fases anteriores limitam o que um pod pode pedir. Esta trata do caso em que alguém escapa do container mesmo assim, por uma falha no kernel ou no runtime.

Num container comum, o usuário de dentro é o mesmo de fora. Um processo que roda como root no container e escapa é root no Pi. Um que roda como uid 1000 vira, no Pi, o uid 1000, que no meu caso é o meu usuário, dono das chaves SSH e da senha do backup.

O user namespace faz uma tradução. Cada pod recebe uma faixa própria de 65.536 uids altos no host, e os uids de dentro são mapeados para ela. O root de dentro passa a ser, no Pi, algo como o uid 3.526.886.376, que não é dono de arquivo nenhum e não tem privilégio nenhum. No Kubernetes, isso é uma linha no pod:

spec:
  hostUsers: false

O cuidado é com os volumes: os arquivos no disco pertencem a uids normais, e o processo agora tem um uid alto. O kubelet resolve isso montando os volumes com tradução de uids (idmapped mounts), e para o processo os arquivos continuam com os mesmos donos. No laboratório, um pod rodou no host como o uid 3.526.886.376 e leu e gravou nos volumes normalmente. O k3s 1.35.9, com o kernel 6.18 do Pi, já tinha tudo o que era preciso.

Onde apliquei

Na ordem do tamanho do risco:

  1. Mempalace. Rodava como uid 1000, ou seja, como o meu usuário no Pi.
  2. Gitea. Rodava como root, com o SSH aceitando conexões da rede de casa. Quem escapasse por ali seria root no Pi. A troca deixou o Gitea 28 segundos fora do ar.
  3. Pods de teste e de deploy do CI. Rodam como root código que eu não escrevi (npm ci, go mod download).
  4. Os pods dos runners, o Postgres e os túneis.

Depois de cada um, a mesma validação: um pipeline de CI completo, o backup, os túneis reconectando, o site respondendo e os testes de rede.

Deixei de fora o pod de build, achando que bastava o isolamento que o BuildKit rootless cria para os passos do build. Estava errado: o BuildKit em si rodava com o uid 1000, que no Pi é o meu usuário, e o container do job rodava como root do Pi. Descobri isso no dia 19, pelo alerta de fuga de container do próximo post, que disparava a cada build. A correção saiu no mesmo dia: o pod de build também ganhou o user namespace, e o BuildKit passou a rodar como root dentro do pod, que no Pi é um uid alto sem privilégio nenhum.

Os apps dos meus outros repositórios ficaram de fora no começo. Eu achava que eles já rodavam com usuários que não existem no Pi, então o ganho seria pequeno. Era verdade para o chat do site, que roda como o uid 10001. Não era para os quatro sites servidos por nginx: o uid 101 da imagem nginx-unprivileged é, no Pi, o usuário do avahi-daemon, o serviço que anuncia o nome do Pi na rede de casa. Os quatro rodavam com a mesma identidade desse serviço e uns dos outros, e uma fuga de qualquer um alcançaria os outros três. Achei isso no dia 29, conferindo o uid com getent passwd 101, e no mesmo dia os cinco apps ganharam o user namespace. Antes de escolher o uid de um container sem user namespace, vale fazer essa conferência.

Nesse dia aprendi também uma lição de outro tipo: mergeei os quatro PRs de uma vez, e os quatro pipelines rodaram juntos. O Pi chegou a 15 de carga com 30 MB de memória livre, o banco do k3s não conseguiu gravar a tempo e o k3s caiu, voltando sozinho em 50 segundos. Num nó de 4 GB, é um merge por vez.

Continuam de fora os componentes internos do k3s.


O Gitea sem root

Depois da Fase 5 o Gitea deixou de ser urgente, mas era o único pod que segurava apps em baseline. A imagem padrão do Gitea começa como root porque roda dois programas, o próprio Gitea e um servidor SSH, e precisa trocar de usuário para isso. Existe uma versão -rootless, que roda direto como o usuário git, com um servidor SSH embutido no próprio Gitea na porta 2222 (sem root não dá para usar a 22).

Para quem usa o Gitea, a troca precisava passar despercebida:

  • Os dados ficaram onde estavam. A imagem rootless espera outra estrutura de pastas; algumas variáveis de ambiente fazem ela usar a que já existia. Nada foi copiado nem movido.
  • A identidade do servidor SSH é a mesma. O servidor embutido usa as chaves que o anterior tinha gerado em junho, então quem já clonava não recebe o aviso de que o servidor mudou.
  • A porta só muda dentro do cluster. Por fora, continua a 22 de sempre, e o Service encaminha para a 2222.

Testei num laboratório montado a partir do último backup: clone e push por SSH, API, registry e os comandos de administração. Foi esse laboratório que achou o problema mais sério da semana, e ele não tinha nada a ver com a imagem.

O backup que restaurava um Gitea quebrado

O primeiro push no laboratório foi aceito, mas a branch não apareceu no Gitea. O Gitea fica sabendo de um push pelos hooks do git, scripts que o git executa ao receber commits. Na cópia restaurada, esses scripts tinham perdido a permissão de execução.

A causa estava no backup. Ele copiava os dados do pod com kubectl cp, que não preserva permissões nem datas dos arquivos. Todos os snapshots tinham os hooks sem permissão de execução e as chaves SSH legíveis por qualquer usuário. Um Gitea restaurado de qualquer um deles aceitaria pushes e nunca mais atualizaria branches nem pull requests, sem mostrar erro nenhum.

Dois dias antes eu tinha feito um teste de restauração completo. Ele conferiu a integridade dos bancos, a contagem de repositórios e os arquivos do registry, e passou. Não pegou o problema porque não fez nenhum push.

A correção teve três partes. O backup passou a copiar com tar, que preserva permissões e datas. A restauração passou a recriar os hooks, para cobrir os snapshots antigos. E, como a imagem rootless não traz o sqlite3, que o backup usava para copiar o banco, essa cópia passou a ser feita por um pod temporário. De quebra, com as datas preservadas, o backup noturno deixou de reler os cerca de 2.100 arquivos e passou a ler só os que mudaram.

Em produção, a ordem foi: backup antes; a troca, com 28 segundos fora do ar; teste de SSH a partir do desktop, conferindo a identidade do servidor; um pipeline de CI completo; o backup novo; os testes de rede. Só então o namespace apps subiu para restricted, depois de o dry-run mostrar que nenhum pod seria recusado. Hoje, entre os namespaces que eu administro, só o ci não está em restricted.


Fase 6: AppArmor

O que é

O user namespace trata do que acontece depois de uma fuga. O AppArmor atua antes: é um módulo de segurança do kernel que limita o que cada processo pode fazer, por meio de perfis: quais arquivos lê e escreve, se pode montar sistemas de arquivos, quais capacidades de root usa.

Com o AppArmor ligado no nó, o containerd aplica um perfil padrão a todo container, sem mudar nenhum manifesto. Esse perfil nega montagens, escrita em /proc/sys e /sys, e acesso a /proc/kcore. São superfícies do kernel que um app comum não usa e que várias fugas de container usam. O perfil padrão não impede um processo invadido de abrir um shell ou ler os arquivos do próprio container; para isso seria preciso um perfil por app.

O que fiz

O kernel do Raspberry Pi OS tem o AppArmor compilado, mas não o carrega no boot: a lista de módulos de segurança vem vazia, e só o capability carrega.

$ cat /sys/module/apparmor/parameters/enabled
N

Ligar é um parâmetro na linha de boot (/boot/firmware/cmdline.txt) e um reboot:

lsm=capability,apparmor

O obstáculo: o BuildKit

O BuildKit da Fase 2 monta sistemas de arquivos para isolar os passos do build, e o perfil padrão nega montagens. Com o AppArmor ligado, todo build falharia.

Rodar o BuildKit sem perfil (Unconfined) é proibido pelo baseline da Fase 3. A saída foi a mesma do seccomp: um perfil próprio, do tipo Localhost, que o baseline aceita. Ele libera as montagens e mantém as negações do perfil padrão que nenhum build usa:

profile buildkit-rootless flags=(attach_disconnected,mediate_deleted) {
  include <abstractions/base>
  capability, file, mount, umount, remount, pivot_root, network, unix, signal, userns,
  deny @{PROC}/sysrq-trigger rwklx,
  deny @{PROC}/kcore rwklx,
  deny /sys/firmware/** rwklx,
  deny /sys/kernel/security/** rwklx,
}

Nos templates de build, ele fica ao lado do perfil seccomp:

securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/buildkit-rootless.json
  appArmorProfile:
    type: Localhost
    localhostProfile: buildkit-rootless

A ordem importa: o kubelet recusa um pod que pede perfil AppArmor num nó sem AppArmor. Primeiro o reboot, depois os templates.

Como conferir

$ cat /sys/module/apparmor/parameters/enabled
Y
$ ps -eo label,comm | grep cri-containerd | head -2
cri-containerd.apparmor.d (enforce) nginx
cri-containerd.apparmor.d (enforce) site-chat

Depois do reboot, todos os containers rodavam no perfil padrão, e um build em cada runner passou.

Falta saber se alguma regra barra algo que deveria funcionar. Com o auditd do próximo post ligado, as negações do AppArmor não vão para o journalctl -k, e sim para o log de auditoria, que só o root lê. Olhar no lugar errado dá zero e não prova nada. O comando certo:

sudo ausearch -m AVC -ts boot | grep -c 'apparmor="DENIED"'

Resultado: zero. Custo: um reboot e nenhum pod a mais.


O que ainda falta

  • Os perfis AppArmor são os genéricos. Um perfil próprio para cada site exposto à internet (o chat, este site) poderia negar o que o perfil padrão deixa, como abrir um shell ou escrever fora da pasta de dados. Isso exige observar cada app por alguns dias em modo de aviso antes de bloquear.
  • O ci continua em baseline, pelos motivos da Fase 3. Subir exigiria imagens de teste sem root e outro jeito de construir imagens.
  • O metrics-server, que vem com o k3s, roda com o meu uid e sem user namespace. O k3s reescreve o manifesto dele a cada reinício, então mudar isso exige desligar o dele e manter um meu.
  • O registry, o Postgres e o Mempalace falam HTTP, sem TLS. A proteção é só serem acessíveis pela rede de casa e pela VPN privada.
  • Tudo roda num nó só. Nada disso protege contra o cartão SD morrer. Para isso existe o backup, que neste mês recebeu automação para enviar cópias fora do Pi.
  • Nenhuma dessas fases avisa que um ataque aconteceu. Elas dificultam o ataque, mas não o detectam. Esse é o assunto do próximo post.
  • Nenhuma delas diz quem está acessando os sites. Esse é o assunto do terceiro.

Como trabalhei

Todas as fases seguiram o mesmo roteiro, e é ele que eu levaria para outro cluster:

  1. Testar num laboratório antes. Um namespace temporário, a mesma imagem, medir o comportamento de verdade e apagar depois. Quando possível, sobre uma cópia restaurada do backup, o que testa o backup junto.
  2. Uma mudança por PR, com o que foi medido na mensagem do commit.
  3. Validar com uso real: um pipeline inteiro, um push, um túnel reconectando. O teste de restauração que só conferiu os bancos mostra por que olhar só o estado não basta.
  4. Deixar um teste que dá para repetir: os testes de rede, o kubectl auth can-i, o dry-run do Pod Security.
  5. Documentar como desfazer cada mudança.