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

Scrub automático de arquivos sensíveis (v2.61.0+)

A partir da v2.61.0 o Runner exclui automaticamente do artefato deployado (src/<instancia>/<versao>/) os arquivos que bots varrem em produção:

  • .env*, .secrets*, .secret
  • .deploy.yml, .gitops.yml, .runner/
  • docker-compose*.yml
  • Configs de CI: .github/, .circleci/, .gitlab-ci.yml, .travis.yml, Jenkinsfile, .drone.yml, bitbucket-pipelines.yml
  • Material de chave: *.pem, *.key, *.p12, *.pfx, *.enc, *.jks, id_rsa/id_ed25519, .ssh/
  • Configs de credencial: .npmrc, .yarnrc, .netrc, .git-credentials, .pypirc

Isso fecha os dois vetores: bind-mount (modo IMAGE — um static server servindo o diretório) e contexto do docker build (modo BUILD — COPY . . já não encontra os arquivos). Dockerfile* é preservado (o modo BUILD precisa dele). Não afeta o runtime: o .env efetivo é injetado via --env-file do app root.

Um WARN por deploy resume o que ficou de fora. Se a SUA app realmente precisa de um desses arquivos em runtime (raro), isente por nome:

security:
  allow_sensitive:
    - docker-compose.yml

Quando o `secret_scan` acusa `private_key` (v2.174.1+)

O padrão private_key só é High quando o cabeçalho de chave privada (-----BEGIN ... PRIVATE KEY-----) vem seguido de corpo base64 com 40 ou mais caracteres, na mesma linha ou na linha seguinte. Cabeçalho sem corpo (um regex, um texto de documentação) vira aviso e não derruba o deploy. Arquivo que o scan não consegue ler conta como High.

vendor/, third_party/ e *.min.js não são varridos. Se o falso positivo está em outro lugar, exclua pelo manifesto:

security:
  secret_scan_ignore:
    - "docs/exemplos/**"
    - "src/fixtures/chave-de-teste.pem"

Cada padrão precisa de ao menos um segmento literal. ** e **/*.js são recusados (secret_scan_ignore_amplo_demais), e {} e [] não são aceitos (secret_scan_ignore_sintaxe_nao_suportada). O resumo do scan informa quantos arquivos foram ignorados. Se o grep falhar ao ler parte da árvore, o runner avisa e mantém os achados que já tinha.

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

API remota do runner: header, mTLS ou lista de IPs (v2.175.0+)

Quem expõe a API do runner (runner service expose) escolhe a camada pela ameaça. Elas se combinam, com uma exceção.

Preciso de... Use Observação
Esconder o nome do header padrão ou rotacionar o segredo sem trocar a chave rk_ --auth-header <NOME> --secret-stdin Segredo de 32+ bytes por stdin; o X-API-Key passa a dar 401
Autenticar o client por certificado mTLS (--ca-cert) Não pode ser combinado com a lista de IPs (ip_permitidos_com_mtls)
Limitar de onde a API é alcançável --ip-permitidos 403 ip_nao_permitido antes da autenticação; /health fica livre
runner service config --auth-header X-Meu-Header --secret-stdin < /caminho/segredo
runner service config --ip-permitidos 192.0.2.10/32,198.51.100.0/24

Limite do modo host do Traefik. Com o Traefik na rede do host e sem trusted_proxies, só o loopback é tratado como proxy. O runner lê o X-Forwarded-For apenas de um par confiável; qualquer outro par é julgado pelo próprio IP, e o cabeçalho é ignorado. O status avisa com aviso_modo_host: true. Consequência: no modo host, a lista enxerga o IP do Traefik, não o do client real, a menos que o Traefik esteja ancorado como proxy confiável. Chamadas locais à API com a lista ativa vêm de 127.0.0.1 e levam 403 se ele não estiver na lista.

Atrás da Cloudflare, use --atras-de-proxy cloudflare. Sem isso, o runner vê o IP da Cloudflare e a lista barra (ou libera) a edge inteira, não o seu client. Com a flag, o client passa a ser o CF-Connecting-IP, mas só quando a conexão vem das faixas da Cloudflare embutidas no binário (o status mostra a data da captura e avisa depois de 180 dias; atualize o runner).

runner service config --ip-permitidos 192.0.2.10 --atras-de-proxy cloudflare

Valide pelo caminho real: curl de um IP fora da lista deve devolver 403.

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