Checklist de Hardening

Lista verificável para registrar e deployar uma app nova com a postura de segurança correta desde o dia um.

Destilada do field report de deploys reais que sangraram horas pra contornar bugs e exposição evitável. Cada item tem o que fazer, por que importa e como verificar.

A ordem segue o ciclo de vida real: pré-setup → registro → primeiro deploy → ongoing. Pule itens com checkbox marcado e siga adiante — voltar pra trás é mais caro que avançar.


Fase 0 — Pré-setup (servidor)

Validar uma vez por servidor, antes da primeira app.

0.1 — Runner em versão atual

  • Por que: versões anteriores a v2.23.x tinham bugs de stores divergentes, Traefik path sem validação, .gitignore traps. Cada uma custou ~30min de debug em campo.
  • Verificar:
    runner -V
    # esperado: runner 2.23.7+ (ou superior)
  • Corrigir: runner self-update.

0.2 — `mkey` existe e tem permissão `0600`

  • Por que: a master key encripta todos os ckeys + GitHub tokens em todos os state files. Se vazada, todos os secrets de todas as apps são decifráveis.
  • Verificar:
    ls -la /opt/runner/.keys/master.key
    # esperado: -rw------- 1 root root
  • Corrigir: chmod 600 /opt/runner/.keys/master.key && chown root:root /opt/runner/.keys/master.key.
  • Backup: salve runner mkey export --sealed --json num password manager. Sem mkey o servidor é inrecuperável.

0.3 — Diretório `.keys/` em `0700 root`

  • Por que: evita listagem do dir mesmo se a permissão do file individual for relaxada por engano.
  • Verificar:
    ls -ld /opt/runner/.keys
    # esperado: drwx------ root
  • Corrigir: chmod 700 /opt/runner/.keys && chown root:root /opt/runner/.keys.

0.4 — Traefik container reachable

  • Por que: runner valida o path do dynamic config contra os mounts do container. Sem container traefik rodando ou com nome diferente, a validação é skipada e deploys podem retornar success com 404 silencioso.
  • Verificar:
    docker ps --filter "name=traefik" --format "{{.Names}}\t{{.Status}}"
    docker inspect traefik --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}'
  • Corrigir: se o container tem outro nome, alinhe (traefik é hardcoded como nome esperado). Se o mount real é diferente do paths.traefik_dynamic, configure:
    runner settings set paths.traefik_dynamic <path-dentro-do-mount>

0.5 — Group `devs` existe e está nos perms

  • Por que: state files (0640 root:devs) só são legíveis por root + membros do group devs. Sem o group, operadores não-root precisam de sudo pra qualquer leitura.
  • Verificar:
    getent group devs
    # esperado: devs:x:GID:user1,user2,...
  • Corrigir: groupadd devs && usermod -aG devs <operador>.

0.6 — Logs com perm correta

  • Por que: audit logs (secrets-access.jsonl, insecure-events.jsonl) precisam ser graváveis por root e visíveis pelo group devs.
  • Verificar:
    ls -la /opt/runner/logs/
    # esperado: drwxr-x--- root:devs em /opt/runner/logs (ou similar)

Fase 1 — Pré-registro (repositório)

Antes de rodar runner add.

1.1 — `.gitignore` exclui plaintext env files

  • Por que: previne git add . de outro contribuidor vazar .env/.secrets plain pro repo público.
  • Verificar:
    grep -E "^\.env|^\.env\.\*|^\.secrets|^\.secrets\.\*" .gitignore
    # esperado: as 4 linhas presentes (ou padrões equivalentes)
  • Corrigir:
    cat >> .gitignore <<'EOF'
    # Plaintext env files (encrypted bundle is in .runner/secrets.enc)
    .env
    .env.*
    .secrets
    .secrets.*
    EOF
  • Bypass: runner secrets push (v2.23.2+) auto-injeta esses 4 patterns se estiverem ausentes — mas é mais limpo já vir do template.

1.2 — `.gitignore` não bloqueia `.runner/secrets.enc`

  • Por que: .runner/ literal no .gitignore (template comum) impede git de descer no diretório, e a negação !.runner/secrets.enc não funciona quando o parent está ignorado. O encrypted bundle nunca chega ao repo → recovery zero-touch quebrado.
  • Verificar:
    grep -E "^\.runner/?$" .gitignore && echo "PROBLEMA" || echo "OK"
    # esperado: OK (sem matches)
  • Corrigir: trocar .runner/ por .runner/* + adicionar negação:
    - .runner/
    + .runner/*
    + !.runner/secrets.enc
  • Bypass: v2.23.2+ reescreve isso automaticamente em runner secrets push ou add, mas já vir do template evita ruído no commit history.

1.3 — `.deploy.yml` declara secrets explicitamente

  • Por que: auto-detect por nome ainda pode falhar pra keys com padrões não-cobertos. Declarar em secrets: block garante.
  • Verificar: abra .deploy.yml. Qualquer key sensível (DATABASE_URL, *_TOKEN, *_KEY, *_PASSWORD, *_SECRET) deve estar em secrets: block, não environment:.
  • Corrigir:
    environment:
      APP_NAME: "{{::AppName?my-app}}"   # público OK aqui
      BASE_URL: "{{::Domain?example.com}}"
    secrets:
      DATABASE_URL: "{{::DATABASE_URL}}"   # sensível
      JWT_SECRET: "${GENERATE:hex:64}"     # auto-gerado
      OPENAI_API_KEY: ""                   # placeholder, preencher via `runner secrets set`

1.4 — `build.context` aponta pro diretório certo

  • Por que: se o Dockerfile está em backend/Dockerfile e o contexto deve ser só backend/, declare. Antes da v2.23.3 o campo era ignorado; agora é respeitado. Mas configs com dockerfile: backend/Dockerfile sem context: mandavam o repo inteiro pro daemon — desperdício de banda e potencial vazamento de files irrelevantes pro build.
  • Verificar:
    build:
      context: backend     # raiz do build context (não repo root)
      dockerfile: Dockerfile  # relativo ao context

1.5 — `version.source` resolvível antes do primeiro deploy

  • Por que: se você setou version.source: file apontando pra package.json ou version.json, o file precisa existir no commit que vai ser deployado. Caso contrário, primeiro deploy falha com erro mostrando os 4 paths tentados.
  • Verificar:
    ls $(grep "^  file:" .deploy.yml | awk '{print $2}')
  • Alternativa: version.source: git-commit evita o problema (usa o hash do commit como versão).

Fase 2 — Registro (runner add)

2.1 — `runner add` com `--ckey` explícito

  • Por que: sem ckey, secrets ficam só em plaintext em disk (.env/.secrets), sem backup encriptado pro repo e sem recovery zero-touch em outro server.
  • Verificar:
    runner add --repo owner/app --branch main --ckey "<senha-forte-min-8-chars>" ...
  • Backup do ckey: salve em password manager. Sem ckey é impossível decifrar .runner/secrets.enc.
  • Anti-órfão: se você rodou runner ckeys add <project> antes do runner add, o runner herda automaticamente desde v2.23.0. Se quer reciclar uma ckey, passe runner add sem --ckey.
  • App já registrada sem ckey (v2.43.3+): não é preciso remover e re-adicionar. runner deploy <app> --ckey anexa um ckey a um app existente que ainda não tem um — --ckey sozinho gera um novo, --ckey <valor> usa o informado. No-op se o app já tiver ckey.

2.2 — Token via `--token-from` quando possível

  • Por que: evita re-colar PAT na linha de comando (vaza no shell history). --token-from <other-app> reusa o token já encriptado de outra app do mesmo owner.
  • Verificar:
    runner tokens list   # mostra apps que já têm token registrado
    runner add --token-from outra-app-do-mesmo-owner ...

2.3 — `runner secrets push` confirmou bootstrap

  • Por que: auto-bootstrap em runner add é best-effort; se falhar (.gitignore exótico, token sem push perm), .runner/secrets.enc fica só local — recovery em outro server impossível.
  • Verificar:
    gh api repos/owner/app/contents/.runner/secrets.enc --jq .size
    # esperado: número (bytes do bundle)
  • Corrigir: runner secrets push <app> manual.

2.4 — State file está em `0640 root:devs`

  • Por que: estado da app contém token e ckey encriptados + metadata sensível (repo URL, deploy history). World-readable vaza sem motivo.
  • Verificar:
    stat -c "%a %U:%G %n" /opt/runner/state/<app>.yml
    # esperado: 640 root:devs
  • Migração: v2.23.7+ migra 0644 → 0640 automaticamente em qualquer load. Pra forçar em massa: runner status --json >/dev/null.

Fase 3 — Primeiro Deploy

3.1 — Pre-flight `secrets:` resolve

  • Por que: pre-flight check refuse deploy se algum secret declarado em .deploy.yml está vazio. Falha cedo é melhor que container subindo com env vazio.
  • Verificar:
    runner manifest <app> --resolved | grep -E "<UNRESOLVED|^secrets:" -A 20
    # esperado: zero <UNRESOLVED:KEY>
  • Corrigir: preencher via runner secrets set <app> <KEY> <VALUE> ou via wizard interativo do runner add.

3.2 — Traefik path validado pelo runner

  • Por que: o runner inspeciona o container traefik antes do deploy. Se inalcançável, refuse com hint. Em modo External ou ausência de container, valida skipa silenciosamente.
  • Verificar:
    runner deploy <app> -V 2>&1 | grep -i "Traefik path"
    # esperado: "Traefik path validated: traefik reads from ..."

3.3 — Health check passa

  • Por que: se health falha, runner reverte automaticamente pra versão anterior. Mas se é o primeiro deploy, não há "anterior" — runner bail explicitamente com instrução pra inspecionar docker logs <container>.
  • Verificar:
    runner deploy <app>
    # esperado: terminar com "✓ DEPLOY CONCLUÍDO" + healthy status
    curl -sk https://<dominio>/health
    # esperado: HTTP 200

3.4 — Cert SSL válido (não default)

  • Por que: Traefik serve cert default quando não encontra dynamic config — sintoma claro de Traefik path inalcançável.
  • Verificar:
    echo | openssl s_client -servername <dominio> -connect <dominio>:443 2>/dev/null | openssl x509 -noout -subject
    # esperado: subject=CN=<dominio> ou wildcard apropriado

Fase 4 — Ongoing (operação)

4.1 — Audit log de secrets ligado

  • Por que: secrets show legítimo precisa de trail pra revisão pós-incidente. Sem isso, "quem viu o quê" é não-respondível.
  • Verificar:
    ls -la /opt/runner/logs/secrets-access.jsonl
    # esperado: existe, 0640 root:devs
    tail -1 /opt/runner/logs/secrets-access.jsonl | jq .
    # esperado: JSON com timestamp, action, app, keys (nomes), user, uid
  • Análise sugerida:
    jq -c 'select(.action == "show")' /opt/runner/logs/secrets-access.jsonl | tail -50

4.2 — Backups regulares de mkey + ckeys

  • Por que: server pode pifar fisicamente. Sem mkey, todos os tokens + ckeys são inrecuperáveis. Sem ckey, secrets de cada app idem.
  • Verificar: alguém em algum lugar guarda o output sealed atual de:
    runner mkey export --sealed --json    # único pro servidor
    runner ckeys export <app> --json       # uma por app que importa
  • Periodicidade: após cada runner mkey rotate ou ao adicionar app importante.

4.3 — `runner self-update` quando tag estável sai

  • Por que: cada release fecha bug encontrado em campo. Atrasar atualização é colecionar dívida técnica de segurança.
  • Verificar:
    curl -s https://runner.ccs.systems/version.txt | head -1
    runner -V
    # se diferentes: runner self-update

4.4 — Cron `runner fetch --deploy` rodando

  • Por que: sem cron, deploys novos esperam intervenção manual e podem ser esquecidos.
  • Verificar:
    crontab -l | grep "runner fetch"
    # esperado: */5 * * * * /usr/local/bin/runner fetch --deploy

Anti-padrões a evitar

Anti-padrão Por que evitar
--ckey "12345" ou ckey curta Trivial brute force. Use --ckey "$(openssl rand -base64 24)".
Commitar .env/.secrets plain "só pra testar" Vai pro history do git. Use runner secrets set.
Editar /opt/runner/state/*.yml à mão Quebra ID hash do bundle, próximo load fica inconsistente. Use runner edit.
Rodar runner --insecure em prod sem motivo Vira audit event vermelho. Não use como atalho.
Ignorar warnings de runner deploy -V Phase 1.6 e Phase 8 emitem info crucial pra debug futuro.
Adicionar usuário ao group docker sem MFA Acesso ao socket Docker = root no host. Trate como root.

Checklist resumida (copie pro seu doc interno)

Servidor (uma vez):
[ ] runner v2.23.7+ instalado
[ ] /opt/runner/.keys/master.key em 0600 root:root
[ ] /opt/runner/.keys em 0700 root
[ ] traefik container rodando, mounts cross-checked com paths.traefik_dynamic
[ ] group devs existe e operadores são membros
[ ] mkey backup salvo em password manager

Repositório (por app):
[ ] .gitignore exclui .env / .env.* / .secrets / .secrets.*
[ ] .gitignore NÃO tem .runner/ literal (use .runner/* + !.runner/secrets.enc)
[ ] .deploy.yml declara secrets sensíveis em secrets: block (não environment:)
[ ] build.context aponta pra dir correto
[ ] version.source resolvível no primeiro deploy

Registro:
[ ] runner add --ckey "<senha-forte>" passado
[ ] ckey salvo em password manager
[ ] --token-from usado quando possível
[ ] runner secrets push confirmou .runner/secrets.enc no repo
[ ] state file em 0640 root:devs

Primeiro deploy:
[ ] runner manifest --resolved sem <UNRESOLVED:KEY>
[ ] Traefik path validated no log do deploy
[ ] /health responde 200
[ ] Cert SSL é o domínio correto (não default)

Ongoing:
[ ] /opt/runner/logs/secrets-access.jsonl recebe escritas
[ ] runner self-update rodado quando nova tag stable sai
[ ] cron */5 runner fetch --deploy ativo

Veja também

By Borlot.com.br on 05/07/2026